5 Commits

Author SHA1 Message Date
Michael G c16f1533e5 (feat) Apple Silicon macOS CI building and Setup.pkg documentation (#118)
* docs(macos): document Setup.pkg installation

Add Apple Silicon macOS CI coverage for runtime configuration, substrate tests, and Setup.pkg packaging alongside the macOS installation instructions.

* macos: pin Apple Silicon deployment target

* fix(macos): restore Retro Rewind local builds
2026-09-05 10:13:55 +02:00
patchzyy 8c6c177857 Simplify README by removing redundant input details 2026-09-05 10:11:48 +02:00
Nicholas Bly be153e0fa0 Add Dolphin-compatible input expressions and GCPadNew.ini import, DualSense L/R remapping, vibration toggle (#89)
* Add Dolphin-compatible input expressions and GCPadNew.ini import

Rebased onto current main; addresses both CodeRabbit reviews on #89.

- Expression engine matching Dolphin's semantics: doubles rather than
  booleans, 0.5 press threshold, & as min, | as max, and the functions if,
  min, max, clamp, abs, sqrt, pow, sin, cos, tan, deadzone, timer, toggle,
  hold, tap, pulse and smooth. Timing uses a steady clock in seconds, as
  Dolphin does, so a copied expression behaves identically.

- Expressions bind to the GameCube buttons and triggers, combined with the
  existing button mapping rather than replacing it, and are skipped while
  the settings overlay holds input.

- Import reads [GCPadN] from the Dolphin config directory or from
  GCPadNew.ini beside the executable. Stick axes are not expression driven
  and keep their normal mapping.

- Fixes #74: a digital button bound to L or R now reports a fully pulled
  analog trigger, plus a PlayStation preset and a vibration toggle.

Review fixes: config paths round-trip through RuntimeConfigFile::PathToUtf8
and PathFromUtf8 so non-ASCII paths open correctly on Windows, and the
duplicated exists branch is gone; the tap count is clamped before the
unsigned conversion; the expression editor uses resizable storage via
ImGuiInputTextFlags_CallbackResize so a long expression cannot be saved
truncated; clamp bounds are ordered before std::clamp; <cstdlib> is included
for std::strtod; non-finite values are rejected at the evaluator boundary as
well as at the deadzone and timer divisions; and InputBindings::Reload() runs
from InitializeRuntimeSettings rather than the vibration handler.

runtime/tests/test_expr.cpp covers operator precedence, each stateful
function and every case raised in review.

Third review round: smooth() guards NaN as well as infinity so a zero rate
cannot latch a non-finite value in node state; division evaluates both operands
so stateful functions in the left subtree still update when the divisor is zero;
the expression editor clears stale errors when the port changes; and
runtime/tests/test_expr.cpp is registered with CTest as mkw_input_expr_tests,
following the existing test targets.

* Update runtime/src/input_expr.cpp

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update runtime/src/input_expr.cpp

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update runtime/src/input_expr.cpp

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-09-05 10:07:05 +02:00
patchzyy e34f055b3a Shortcut (#155) 2026-09-05 09:55:09 +02:00
patchzyy 602348f905 Update Models.cs 2026-09-04 22:00:21 +02:00
21 changed files with 1896 additions and 122 deletions
+31
View File
@@ -34,3 +34,34 @@ jobs:
- name: Test
run: dotnet test translator/Translator.sln -c Release --no-build --verbosity normal
macos_substrate:
name: macOS arm64 (configure + substrate tests)
runs-on: macos-14
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Configure native runtime
shell: bash
run: |
test "$(uname -m)" = arm64
cmake -S runtime -B build-macos -G Ninja \
-DCMAKE_BUILD_TYPE=Release -DMKW_BUILD_PRODUCTS=OFF
grep -qx 'CMAKE_OSX_DEPLOYMENT_TARGET:STRING=14.0' \
build-macos/CMakeCache.txt
- name: Build macOS portability targets
shell: bash
run: |
cmake --build build-macos --target \
mkw_platform_paths_tests \
mkw_macos_native_compile \
mkw_macos_context_abi_tests \
mkw_macos_host_context_tests \
mkw_macos_guest_flat_memory_tests
- name: Test execution substrate
shell: bash
run: ctest --test-dir build-macos --output-on-failure
+96 -6
View File
@@ -1,8 +1,8 @@
name: Package installers
# Builds the per-platform installer/setup tool (WiiCompiled-Setup.exe /
# WiiCompiled-Setup-x86_64.AppImage) via Launcher/Build-Installer.ps1 and
# Launcher/build-appimage.sh respectively - the same scripts a maintainer runs by hand today to
# WiiCompiled-Setup-x86_64.AppImage / WiiCompiled-Setup.pkg) via the platform packaging scripts -
# the same scripts a maintainer runs by hand today to
# produce a GitHub Release asset. This does NOT build the actual translated game executable:
# that step requires the end user's own Mario Kart Wii dump (Assets/main.dol, Assets/StaticR.rel),
# which is proprietary and not present in this repository or in CI.
@@ -11,6 +11,11 @@ on:
tags:
- '*'
workflow_dispatch:
inputs:
version:
description: Package version
required: true
type: string
permissions:
contents: read
@@ -86,14 +91,99 @@ jobs:
if-no-files-found: error
archive: false
macos-setup-package:
name: macOS (Setup.pkg, Apple Silicon)
runs-on: macos-14
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '8.0.x'
- name: Verify Apple Silicon runner tools
shell: bash
run: |
test "$(uname -m)" = arm64
xcode-select -p
command -v ninja
file "$(command -v ninja)" | grep -q arm64
- name: Download pinned nodtool release
shell: bash
run: |
mkdir -p Launcher/artifacts/macos
nodtool_version=v2.0.0-alpha.10
nodtool_asset=nodtool-macos-arm64
nodtool_sha256=e23ca466999b720c55e6d29c9683fce8cc74451ba64ead2e543d50129f24528a
curl -fsSL --retry 3 \
"https://github.com/encounter/nod/releases/download/${nodtool_version}/${nodtool_asset}" \
-o Launcher/artifacts/macos/nodtool
printf '%s %s\n' "$nodtool_sha256" Launcher/artifacts/macos/nodtool | shasum -a 256 -c -
chmod +x Launcher/artifacts/macos/nodtool
Launcher/artifacts/macos/nodtool --version
- name: Publish self-contained translator
shell: bash
run: |
dotnet publish translator/src/Translator.Cli/Translator.Cli.csproj \
-c Release -r osx-arm64 --self-contained true \
-p:PublishSingleFile=true \
-o Launcher/artifacts/macos/translator
- name: Download pinned portable CMake
shell: bash
run: |
cmake_version=4.4.3
archive="cmake-${cmake_version}-macos-universal.tar.gz"
base_url="https://github.com/Kitware/CMake/releases/download/v${cmake_version}"
expected_sha256=0c5d65251c14cc884bfa16bdbed3c263ce5bffe2e21c0d0d00962cb0610464fa
curl -fsSL --retry 3 "$base_url/$archive" -o "$archive"
printf '%s %s\n' "$expected_sha256" "$archive" | shasum -a 256 -c -
tar -xzf "$archive"
mv "cmake-${cmake_version}-macos-universal/CMake.app/Contents" Launcher/artifacts/macos/cmake
- name: Build Setup.pkg
env:
PACKAGE_VERSION: ${{ inputs.version }}
TAG_VERSION: ${{ github.ref_name }}
shell: bash
run: |
package_version="$PACKAGE_VERSION"
if [[ -z "$package_version" ]]; then package_version="${TAG_VERSION#v}"; fi
mkdir -p Launcher/dist
Launcher/macos/build-setup-pkg.command \
--nodtool Launcher/artifacts/macos/nodtool \
--translator Launcher/artifacts/macos/translator/Translator.Cli \
--cmake-root Launcher/artifacts/macos/cmake \
--ninja "$(command -v ninja)" \
--output Launcher/dist/WiiCompiled-Setup.pkg \
--version "$package_version"
- name: Verify package boundary
shell: bash
run: |
pkgutil --check-signature Launcher/dist/WiiCompiled-Setup.pkg
! pkgutil --payload-files Launcher/dist/WiiCompiled-Setup.pkg | \
grep -E '/(Assets|generated|PulsarPacks|WiiCompiled.app|RetroRewind.app)(/|$)'
- uses: actions/upload-artifact@v7
with:
name: WiiCompiled-Setup-macos-arm64
path: Launcher/dist/WiiCompiled-Setup.pkg
if-no-files-found: error
archive: false
# Publishes the packaged installers as a GitHub Release whenever a v* tag is pushed. Wheel Wizard
# discovers updates from these releases, so the contract it relies on is enforced here: a full
# (non-prerelease) release whose tag is v<semver>, carrying an asset named exactly
# WiiCompiled-Setup.exe, produced by a setup host that reports that same version.
# (non-prerelease) release whose tag is v<semver>, carrying the expected platform assets,
# produced by setup hosts that report that same version.
release:
name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
needs: [linux-appimage, windows-installer]
needs: [linux-appimage, windows-installer, macos-setup-package]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -139,7 +229,7 @@ jobs:
set -euo pipefail
ls -lR artifacts
assets=()
for name in WiiCompiled-Setup.exe WiiCompiled-Setup-x86_64.AppImage WiiCompiled-Setup-aarch64.AppImage; do
for name in WiiCompiled-Setup.exe WiiCompiled-Setup-x86_64.AppImage WiiCompiled-Setup-aarch64.AppImage WiiCompiled-Setup.pkg; do
found="$(find artifacts -type f -name "$name" | head -n 1)"
[ -n "$found" ] && [ -s "$found" ] || { echo "::error::missing release asset $name"; exit 1; }
assets+=("$found")
+1 -1
View File
@@ -3,7 +3,7 @@ namespace WiiCompiled.Setup.Linux;
internal static class ProductInfo
{
public const string Name = "WiiCompiled";
public const string Version = "0.2.22";
public const string Version = "0.2.27";
}
/// <summary>One installed product's record inside install-state.json.</summary>
@@ -22,6 +22,9 @@ internal sealed class InstallerEngine
var existing = new Installation(installDirectory);
PortableInstallHealing.HealMovedInstall(existing, _reporter);
// Capture this before publishing anything. An existing installation may be repaired or
// updated by this invocation, but those operations must not recreate the user's shortcuts.
var firstInstall = !existing.IsPresent;
var previousState = existing.ReadInstallState();
using var scratch = Directory.Exists(installDirectory)
? InstallScratchSpace.CreateInsideInstall(installDirectory, _reporter)
@@ -139,7 +142,7 @@ internal sealed class InstallerEngine
updatedState.RetroRewindInstalled, candidateRuntimeAssetsFingerprint,
remainingCancellation);
Publish(staging, installDirectory, canonicalRetroRoot, updatedState,
releaseEntries, remainingCancellation);
releaseEntries, remainingCancellation, createShortcuts: firstInstall);
return;
}
@@ -161,7 +164,8 @@ internal sealed class InstallerEngine
await PublishToolkitAndReconcileProductsAsync(existing, staging, workspace, manifest,
previousState, options, canonicalRetroRoot, retroCompileInputs,
publishGameAssets: reusableGameAssets is null, cancellationToken);
publishGameAssets: reusableGameAssets is null, createShortcuts: firstInstall,
cancellationToken: cancellationToken);
}
@@ -186,7 +190,7 @@ internal sealed class InstallerEngine
string stagedWorkspace, PayloadManifest manifest, InstallState? previousState,
InstallOptions options, string? canonicalRetroRoot,
RetroRewindCompileInputs? retroCompileInputs,
bool publishGameAssets, CancellationToken cancellationToken)
bool publishGameAssets, bool createShortcuts, CancellationToken cancellationToken)
{
var installDirectory = existing.Root;
@@ -214,7 +218,8 @@ internal sealed class InstallerEngine
if (publishGameAssets) AddComponent(entries, staging, installDirectory, "GameAssets");
Publish(staging, installDirectory, canonicalRetroRoot, state,
entries, cancellationToken, progressPercent: 8, completionPercent: 10);
entries, cancellationToken, progressPercent: 8, completionPercent: 10,
createShortcuts: createShortcuts);
_reporter.Progress(InstallStages.BuildBase,
"Producing the installed products with the published toolkit...", 11);
@@ -277,7 +282,7 @@ internal sealed class InstallerEngine
private void Publish(string staging, string installDirectory,
string? canonicalRetroRoot, InstallState state,
List<InstallTransactionEntry> entries, CancellationToken cancellationToken,
int progressPercent = 95, int completionPercent = 99)
int progressPercent = 95, int completionPercent = 99, bool createShortcuts = false)
{
entries.Add(InstallTransactionEntry.Directory(Path.Combine(staging, "licenses"),
Path.Combine(installDirectory, "licenses")));
@@ -322,7 +327,8 @@ internal sealed class InstallerEngine
{
ShellIntegration.RegisterUninstaller(installDirectory, state.RetroRewindInstalled);
}
ShellIntegration.CreateShortcuts(installDirectory);
if (createShortcuts)
ShellIntegration.CreateShortcuts(installDirectory);
}
catch (Exception ex)
{
+3 -1
View File
@@ -131,7 +131,9 @@ if (( builds_retro )); then args+=(--resolved-profile "$mod_out/resolved_dispatc
step emit-build-shards 'Preparing native build shards'; translator "${args[@]}"
step configure-native 'Configuring the native toolchain'
"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs"
# Use Aurora's pinned SDL3 source on macOS. A system SDL3 can be older than
# Aurora's required API even when find_package() succeeds.
"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" -DAURORA_SDL3_PROVIDER=vendor
targets=(); [[ "$profile" != retro-rewind ]] && targets+=(WiiCompiled); [[ "$profile" != base ]] && targets+=(RetroRewind)
step compile "Compiling ${targets[*]} locally"; "$cmake_bin" --build "$native_build" --target "${targets[@]}" --parallel "$global_jobs"
if [[ "$profile" != retro-rewind ]]; then "$script_dir/macos/publish-app.command" --build-dir "$native_build" --product WiiCompiled --output-dir "${base_output_dir:-$output_dir}"; fi
+3
View File
@@ -103,6 +103,9 @@ copy_clean "$workspace/Launcher/local-build-macos.command" "$resources/workspace
copy_clean "$workspace/Launcher/macos/extract-disc.command" "$resources/workspace/Launcher/macos/extract-disc.command"
copy_clean "$workspace/Launcher/macos/publish-app.command" "$resources/workspace/Launcher/macos/publish-app.command"
chmod +x "$resources/workspace/Launcher/local-build-macos.command" "$resources/workspace/Launcher/macos/"*.command
# setup.command uses this marker to update source inputs in an existing user
# workspace without replacing extracted game assets or Retro Rewind files.
printf '%s\n' "$version" > "$resources/workspace/.bundle-version"
mkdir -p "$resources/tools/cmake"
copy_clean "$nodtool" "$resources/tools/nodtool"; chmod +x "$resources/tools/nodtool"
copy_clean "$translator" "$resources/tools/Translator.Cli"; chmod +x "$resources/tools/Translator.Cli"
+23 -3
View File
@@ -62,10 +62,30 @@ if ! /usr/bin/xcode-select -p >/dev/null 2>&1; then
fi
mkdir -p "$support_root" "$products"
if [[ ! -d "$workspace/.git" && ! -f "$workspace/projects/mkwii/recomp.yml" ]]; then
source_bundle_version="$workspace_source/.bundle-version"
workspace_bundle_version="$workspace/.bundle-version"
needs_workspace_refresh=0
if [[ ! -f "$workspace/projects/mkwii/recomp.yml" ]]; then
needs_workspace_refresh=1
elif [[ -f "$source_bundle_version" ]] && [[ ! -f "$workspace_bundle_version" || "$(<"$source_bundle_version")" != "$(<"$workspace_bundle_version")" ]]; then
needs_workspace_refresh=1
fi
if (( needs_workspace_refresh )); then
printf 'Preparing the local build workspace...\n'
rm -rf "$workspace"
/usr/bin/ditto "$workspace_source" "$workspace"
if [[ ! -d "$workspace" ]]; then
/usr/bin/ditto "$workspace_source" "$workspace"
else
# Refresh only packaged source inputs. Assets and the staged Retro
# Rewind package belong to the user and stay in place.
for source in aurora-main projects runtime translator Launcher; do
/usr/bin/ditto "$workspace_source/$source" "$workspace/$source"
done
/usr/bin/ditto "$source_bundle_version" "$workspace_bundle_version"
# A dependency provider can be cached in this directory, so make the
# refreshed sources configure from a clean native build tree.
rm -rf "$workspace/native-build-macos"
fi
fi
profile=base
+27 -15
View File
@@ -46,7 +46,9 @@ Press **F10** while the game window has focus:
- Internal resolution
- FPS counter
- Controller assignment for all four ports
- Full per-controller button mapping
- Full per-controller button mapping, including the bumpers
- Dolphin-syntax input expressions and GCPadNew.ini import
- Controller vibration on/off
- Volume, instant mute, and the music ducking toggle
Everything you change is saved to `Config.toml` on the spot and restored next launch.
@@ -56,25 +58,20 @@ Controllers are fed to the game as a GameCube controller.
Mappings are positional (`south`, `east`, `west`, `north`) rather than Xbox-labelled, so the
same config makes sense on Xbox, PlayStation, Nintendo and generic SDL pads alike, and extra
inputs like paddles, touchpads and share buttons show up when the hardware reports them.
**Dolphin-compatible input expressions.**
Each GameCube control can carry an expression in Dolphin's input syntax, with the same operators
and the same functions.
A Dolphin `GCPadNew.ini` can be imported directly from the F10 bar.
**Vibration toggle.**
Force feedback can be turned off for every port at once.
The official Wii U / Switch GameCube adapter (WUP-028) works too; as with Dolphin, on Windows the
adapter must be switched to the WinUSB driver once (Zadig).
**Real Wii Remotes over Bluetooth.**
Pair a Wii Remote with Windows (Settings > Bluetooth > Add device, press 1+2 or SYNC, leave the
PIN empty) and the game reads it as an actual Wii Remote through KPAD: Wii Remote icons and
prompts, Wii Wheel tilt steering, wheelies and tricks all come from the game's own motion code.
Nunchuk and Classic Controller are real Wii extensions too: the game gets the Nunchuk's stick,
C/Z and accelerometer, and the Classic Controller through `KPADGetUnifiedWpadStatus` with its own
layout and icons, so its buttons do what the game says they do and no mapping is involved. Plug an
extension in or pull it out mid-game and the game switches control scheme like on the console
(the runtime patches SDL's Wii driver, which otherwise loses the remote for good on an extension
change). Only the Wii U Pro Controller, which has no Wii-era equivalent, is fed to the game as a
GameCube pad with Nintendo's layout. If a remote drops out or was switched on after launch, the
runtime keeps rescanning Bluetooth until it comes back (F10 > Controller settings > Wii Remotes). SDL's read of
the remote's factory accelerometer calibration often times out over Bluetooth (`console.log`
then says "Using fallback accelerometer calibration") and it falls back to a nominal zero point,
so the same menu has a one-button calibration (remote flat, buttons up) that removes the small
tilt offset some remotes show.
PIN empty)
Known limitations of the Wii Remote path:
- No IR pointer yet: menus are navigated with the D-pad and A (the game treats the remote as
@@ -114,6 +111,21 @@ image under Settings, turn on **WiiCompiled (beta)**, and hit install from the H
Wheel Wizard downloads the setup tool from this repo and walks you through install, updates and
launching. The backend itself is deliberately command-line only, Wheel Wizard is a wrapper around it.
### macOS
Download `WiiCompiled-Setup.pkg` from this repository's Releases page and open it. It requires an
Apple Silicon Mac because its bundled nodtool and Translator.Cli executables are arm64. It installs
**WiiCompiled Setup** in Applications; open that app, choose your clean PAL `RMCP01` disc image,
and select either the base game or Retro Rewind. For Retro Rewind, choose the `RetroRewind6` folder
or its parent folder.
Setup verifies and extracts the image locally, then translates and compiles the native app on your
Mac. On a first run it may ask macOS to install Xcode Command Line Tools; complete Apple's installer,
then open Setup again. When the build completes, Setup asks for administrator approval once to install
`WiiCompiled.app` (and, if selected, `RetroRewind.app`) in `/Applications`.
Setup opens Terminal while it works, so the extraction and build progress—and any error that needs
reporting—remain visible.
> [!CAUTION]
> Only take builds from this repository's
+5 -2
View File
@@ -41,7 +41,10 @@ if (_aurora_sdl3_provider STREQUAL "auto")
set(_aurora_sdl3_provider "package")
else ()
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
find_package(SDL3 QUIET)
# Aurora uses APIs from the SDL version pinned by AURORA_SDL3_VERSION.
# Do not silently select an older system package and fail later while
# compiling its headers.
find_package(SDL3 ${AURORA_SDL3_VERSION} QUIET)
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF)
if (SDL3_FOUND)
set(_aurora_sdl3_provider "system")
@@ -58,7 +61,7 @@ if (_aurora_sdl3_provider STREQUAL "system")
message(STATUS "aurora: Using system SDL3 (provider=system)")
if (NOT SDL3_FOUND)
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
find_package(SDL3 REQUIRED)
find_package(SDL3 ${AURORA_SDL3_VERSION} REQUIRED)
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF)
endif ()
_aurora_sdl3_select_target()
+4 -1
View File
@@ -1438,7 +1438,10 @@ void __PADWriteDeadZones(SDL_IOStream* file, // NOLINT(*-reserved-identifier)
void PADSerializeMappings() {
const std::filesystem::path basePath = fs_path_from_string(aurora::g_config.userPath);
for (auto& controller : aurora::input::g_GameControllers | std::views::values) {
// Avoid std::views::values here: older Apple libc++ releases implement the
// C++20 ranges algorithms we use but not this adaptor.
for (auto& entry : aurora::input::g_GameControllers) {
auto& controller = entry.second;
EnsureMappingLoaded(&controller);
const auto filePath =
basePath / fmt::format("{}_{:04X}_{:04X}.controller", aurora::input::controller_name(controller.m_index),
+1 -1
View File
@@ -235,7 +235,7 @@ std::string GetOSVersion() {
constexpr auto name = "iOS";
#elif TARGET_OS_TV
constexpr auto name = "tvOS";
#elif
#else
constexpr auto name = Unknown;
#endif
+19
View File
@@ -17,6 +17,16 @@ else()
message(FATAL_ERROR
"WiiCompiled supports 64-bit LLVM-MinGW Clang on Windows, native Linux x86_64/aarch64, or Apple Clang on macOS arm64")
endif()
# Do not inherit the host SDK's deployment target (macOS 26 on current
# toolchains). The supported Apple Silicon release is macOS 14 and the app
# bundle advertises that same minimum. This remains arm64-only until the Intel
# support work is merged.
if(MKW_PLATFORM_MACOS)
set(CMAKE_OSX_DEPLOYMENT_TARGET "14.0" CACHE STRING
"Minimum macOS version for the Apple Silicon build" FORCE)
endif()
if(NOT CMAKE_BUILD_TYPE STREQUAL "Release")
message(FATAL_ERROR "WiiCompiled only supports Release builds")
endif()
@@ -277,6 +287,15 @@ target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform)
target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17)
add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests)
# The input expression engine is self-contained, so it can be exercised without
# linking the runtime or SDL.
add_executable(mkw_input_expr_tests
"${CMAKE_CURRENT_LIST_DIR}/tests/test_expr.cpp"
"${CMAKE_CURRENT_LIST_DIR}/src/input_expr.cpp")
target_include_directories(mkw_input_expr_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_input_expr_tests PRIVATE cxx_std_17)
add_test(NAME mkw_input_expr_tests COMMAND mkw_input_expr_tests)
# HostContext deliberately keeps the platform-specific context primitive out
# of fiber_manager.cpp. Exercise the Linux libco handoff directly so future
# refactors cannot silently remove its headers, implementation, or link edge.
+159
View File
@@ -0,0 +1,159 @@
#pragma once
// The single vocabulary shared by everything that has to turn a Config.toml
// controller name into a real button: the F10 settings bar, the macro engine,
// and the startup mapping pass. Keeping one table here means a name that the
// settings bar offers is always a name the config parser accepts, and vice
// versa; the two used to drift because each side carried its own copy.
#include <algorithm>
#include <array>
#include <cstdint>
#include <string>
#include <string_view>
#include <SDL3/SDL_gamepad.h>
#include <dolphin/pad.h>
namespace ControllerNames {
// A GameCube button as the game sees it, with the Config.toml key that selects
// it. Order matches RuntimeConfigFile::kControllerButtonKeys.
struct GameCubeButtonItem {
const char* configKey;
const char* label;
PADButton padButton;
};
inline constexpr std::array<GameCubeButtonItem, PAD_BUTTON_COUNT> kGameCubeButtons = {{
{"a", "A", PAD_BUTTON_A},
{"b", "B", PAD_BUTTON_B},
{"x", "X", PAD_BUTTON_X},
{"y", "Y", PAD_BUTTON_Y},
{"start", "Start", PAD_BUTTON_START},
{"z", "Z", PAD_TRIGGER_Z},
{"l", "L", PAD_TRIGGER_L},
{"r", "R", PAD_TRIGGER_R},
{"up", "D-pad Up", PAD_BUTTON_UP},
{"down", "D-pad Down", PAD_BUTTON_DOWN},
{"left", "D-pad Left", PAD_BUTTON_LEFT},
{"right", "D-pad Right", PAD_BUTTON_RIGHT},
}};
// A physical button on the host pad. Names are positional (south/east/...)
// rather than Xbox-labelled so one config reads the same on any hardware.
struct NativeButtonItem {
const char* configName;
const char* label;
uint32_t nativeButton;
};
inline constexpr std::array<NativeButtonItem, SDL_GAMEPAD_BUTTON_COUNT + 1> kNativeButtons = {{
{"unmapped", "Unmapped / analog trigger", PAD_NATIVE_BUTTON_INVALID},
{"south", "South (A / Cross)", SDL_GAMEPAD_BUTTON_SOUTH},
{"east", "East (B / Circle)", SDL_GAMEPAD_BUTTON_EAST},
{"west", "West (X / Square)", SDL_GAMEPAD_BUTTON_WEST},
{"north", "North (Y / Triangle)", SDL_GAMEPAD_BUTTON_NORTH},
{"back", "Back / Select / Create", SDL_GAMEPAD_BUTTON_BACK},
{"guide", "Guide / Home / PS", SDL_GAMEPAD_BUTTON_GUIDE},
{"start", "Start / Options", SDL_GAMEPAD_BUTTON_START},
{"left_stick", "Left stick click (L3)", SDL_GAMEPAD_BUTTON_LEFT_STICK},
{"right_stick", "Right stick click (R3)", SDL_GAMEPAD_BUTTON_RIGHT_STICK},
{"left_shoulder", "Left bumper (LB / L1)", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER},
{"right_shoulder", "Right bumper (RB / R1)", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER},
{"dpad_up", "D-pad Up", SDL_GAMEPAD_BUTTON_DPAD_UP},
{"dpad_down", "D-pad Down", SDL_GAMEPAD_BUTTON_DPAD_DOWN},
{"dpad_left", "D-pad Left", SDL_GAMEPAD_BUTTON_DPAD_LEFT},
{"dpad_right", "D-pad Right", SDL_GAMEPAD_BUTTON_DPAD_RIGHT},
{"misc1", "Misc 1 / Share / Mic", SDL_GAMEPAD_BUTTON_MISC1},
{"right_paddle1", "Right paddle 1", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1},
{"left_paddle1", "Left paddle 1", SDL_GAMEPAD_BUTTON_LEFT_PADDLE1},
{"right_paddle2", "Right paddle 2", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2},
{"left_paddle2", "Left paddle 2", SDL_GAMEPAD_BUTTON_LEFT_PADDLE2},
{"touchpad", "Touchpad click", SDL_GAMEPAD_BUTTON_TOUCHPAD},
{"misc2", "Misc 2", SDL_GAMEPAD_BUTTON_MISC2},
{"misc3", "Misc 3 / GC L click", SDL_GAMEPAD_BUTTON_MISC3},
{"misc4", "Misc 4 / GC R click", SDL_GAMEPAD_BUTTON_MISC4},
{"misc5", "Misc 5", SDL_GAMEPAD_BUTTON_MISC5},
{"misc6", "Misc 6", SDL_GAMEPAD_BUTTON_MISC6},
}};
inline std::string TrimToken(std::string_view token) {
const size_t begin = token.find_first_not_of(" \t");
if (begin == std::string_view::npos) {
return {};
}
const size_t end = token.find_last_not_of(" \t");
return std::string(token.substr(begin, end - begin + 1));
}
inline const NativeButtonItem* FindNativeButton(std::string_view configName) {
const std::string name = TrimToken(configName);
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(),
[&](const NativeButtonItem& item) { return name == item.configName; });
return it == kNativeButtons.end() ? nullptr : &*it;
}
// Falls back to the "unmapped" entry so callers always have a label to draw.
inline const NativeButtonItem& NativeButtonForValue(uint32_t nativeButton) {
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(),
[&](const NativeButtonItem& item) { return nativeButton == item.nativeButton; });
return it == kNativeButtons.end() ? kNativeButtons.front() : *it;
}
inline const GameCubeButtonItem* FindGameCubeButton(std::string_view configKey) {
const std::string key = TrimToken(configKey);
const auto it = std::find_if(kGameCubeButtons.begin(), kGameCubeButtons.end(),
[&](const GameCubeButtonItem& item) { return key == item.configKey; });
return it == kGameCubeButtons.end() ? nullptr : &*it;
}
// "up" or "up,a" -> the OR of those GC button bits. Unknown names are skipped so
// a typo costs one button instead of the whole macro.
inline uint16_t GameCubeMaskFromKeys(std::string_view keys) {
uint16_t mask = 0;
size_t begin = 0;
while (begin <= keys.size()) {
const size_t comma = keys.find(',', begin);
const std::string_view token =
keys.substr(begin, comma == std::string_view::npos ? std::string_view::npos : comma - begin);
if (const GameCubeButtonItem* item = FindGameCubeButton(token)) {
mask |= static_cast<uint16_t>(item->padButton);
}
if (comma == std::string_view::npos) {
break;
}
begin = comma + 1;
}
return mask;
}
inline std::string GameCubeKeysFromMask(uint16_t mask) {
std::string keys;
for (const auto& item : kGameCubeButtons) {
if ((mask & static_cast<uint16_t>(item.padButton)) == 0) {
continue;
}
if (!keys.empty()) {
keys += ',';
}
keys += item.configKey;
}
return keys;
}
inline std::string GameCubeLabelsFromMask(uint16_t mask) {
std::string labels;
for (const auto& item : kGameCubeButtons) {
if ((mask & static_cast<uint16_t>(item.padButton)) == 0) {
continue;
}
if (!labels.empty()) {
labels += " + ";
}
labels += item.label;
}
return labels.empty() ? std::string("None") : labels;
}
} // namespace ControllerNames
+67
View File
@@ -0,0 +1,67 @@
#pragma once
// Per-port expression bindings for the GameCube controls, plus import of a
// Dolphin GCPadNew.ini.
#include <array>
#include <cstdint>
#include <string>
#include <dolphin/pad.h>
namespace InputBindings {
// The controls an expression can drive, in Dolphin's own naming so an
// imported config maps across without translation.
struct ControlInfo {
const char* dolphinName;
const char* label;
uint16_t padButton; // 0 for the analog-only controls below
int analog; // 0 none, 1 trigger L, 2 trigger R
};
inline constexpr std::array<ControlInfo, 14> kControls = {{
{"Buttons/A", "A", PAD_BUTTON_A, 0},
{"Buttons/B", "B", PAD_BUTTON_B, 0},
{"Buttons/X", "X", PAD_BUTTON_X, 0},
{"Buttons/Y", "Y", PAD_BUTTON_Y, 0},
{"Buttons/Z", "Z", PAD_TRIGGER_Z, 0},
{"Buttons/Start", "Start", PAD_BUTTON_START, 0},
{"D-Pad/Up", "D-pad Up", PAD_BUTTON_UP, 0},
{"D-Pad/Down", "D-pad Down", PAD_BUTTON_DOWN, 0},
{"D-Pad/Left", "D-pad Left", PAD_BUTTON_LEFT, 0},
{"D-Pad/Right", "D-pad Right", PAD_BUTTON_RIGHT, 0},
{"Triggers/L", "L", PAD_TRIGGER_L, 1},
{"Triggers/R", "R", PAD_TRIGGER_R, 2},
{"Triggers/L-Analog", "L analog", 0, 1},
{"Triggers/R-Analog", "R analog", 0, 2},
}};
void Reload() noexcept;
// The pad library has PADBlockInput but no matching query, so the settings
// overlay reports its own state here.
void SetInputBlocked(bool blocked) noexcept;
bool InputBlocked() noexcept;
// Mix expression output into a freshly read status set. Call once per guest
// PADRead, after every other input source has been merged.
void Apply(PADStatus* statuses) noexcept;
std::string GetExpression(uint32_t port, size_t control) noexcept;
// Returns false and fills error if the text does not parse; the binding is
// left unchanged in that case.
bool SetExpression(uint32_t port, size_t control, const std::string& text, std::string& error) noexcept;
// True while the control's expression is above the press threshold.
bool IsActive(uint32_t port, size_t control) noexcept;
// The default Dolphin config location on Windows, then next to the executable.
std::string DefaultDolphinConfigPath() noexcept;
// Imports [GCPad<padIndex>] into the given port. Returns the number of controls
// imported, or -1 on failure with error filled.
int ImportDolphinConfig(const std::string& path, int padIndex, uint32_t port,
std::string& summary, std::string& error) noexcept;
} // namespace InputBindings
+53
View File
@@ -0,0 +1,53 @@
#pragma once
// Dolphin-compatible input expressions.
//
// Values are doubles in Dolphin's ControlState style; a control counts as
// pressed above kConditionThreshold. Timing matches Dolphin: wall-clock
// seconds on a steady clock, so an expression copied from GCPadNew.ini
// behaves the same here as it does there.
#include <filesystem>
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace InputExpr {
inline constexpr double kConditionThreshold = 0.5;
// Resolves a backtick-quoted input name to its current value.
using InputSource = std::function<double(const std::string&)>;
struct Node;
class Expression {
public:
Expression();
~Expression();
Expression(Expression&&) noexcept;
Expression& operator=(Expression&&) noexcept;
// Returns false and fills error on a syntax problem.
static bool Parse(const std::string& text, Expression& out, std::string& error);
bool Empty() const { return m_root == nullptr; }
double Evaluate(const InputSource& source) const;
// Input names the expression references, for diagnostics.
std::vector<std::string> ReferencedInputs() const;
private:
std::unique_ptr<Node> m_root;
};
// Parses a Dolphin GCPadNew.ini and returns the expression text for each
// control of the requested pad, keyed by Dolphin's own control names
// ("Buttons/A", "D-Pad/Up", "Triggers/L", ...). Returns false if the file
// cannot be read or the section is missing.
bool ReadDolphinConfig(const std::filesystem::path& path, int padIndex,
std::vector<std::pair<std::string, std::string>>& controls,
std::string& deviceName, std::string& error);
} // namespace InputExpr
+33
View File
@@ -11,6 +11,7 @@
#include <iomanip>
#include <iostream>
#include <limits>
#include <map>
#include <optional>
#include <sstream>
#include <string>
@@ -89,6 +90,8 @@ struct RuntimeUserConfig {
// comma-separated SDL-style physical button names ("south", or
// "dpad_up,left_shoulder") as values; pressing either bound button counts.
std::array<std::optional<std::string>, 12> controllerButtons;
std::optional<bool> rumbleEnabled;
std::map<std::string, std::string> controllerExpressions;
};
namespace RuntimeConfigFile {
@@ -407,6 +410,17 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
FindConfigValue<std::string>(document, "controller", buttonKeys[index]);
}
config.rumbleEnabled = FindConfigValue<bool>(document, "controller", "rumble");
if (const auto* section = document.contains("controller") ? &document.at("controller") : nullptr;
section != nullptr && section->is_table()) {
for (const auto& [key, value] : section->as_table()) {
if (key.rfind("expr_", 0) == 0 && value.is_string()) {
config.controllerExpressions[key] = value.as_string();
}
}
}
config.widescreen = FindConfigValue<bool>(document, "video", "widescreen");
config.windowPosX = FindConfigInt(document, "video", "window_x");
config.windowPosY = FindConfigInt(document, "video", "window_y");
@@ -677,6 +691,25 @@ inline bool SetControllerButton(size_t index, std::string value) {
return WriteSetting("controller", kControllerButtonKeys[index], FormatString(value));
}
inline std::string ControllerExpression(const std::string& key) {
const auto it = Get().controllerExpressions.find(key);
return it == Get().controllerExpressions.end() ? std::string() : it->second;
}
inline bool SetControllerExpression(const std::string& key, const std::string& value) {
Mutable().controllerExpressions[key] = value;
return WriteSetting("controller", key, FormatString(value));
}
inline bool RumbleEnabled(bool fallback = true) {
return Get().rumbleEnabled.value_or(fallback);
}
inline bool SetRumbleEnabled(bool value) {
Mutable().rumbleEnabled = value;
return WriteSetting("controller", "rumble", value ? "true" : "false");
}
inline bool SetAudioVolume(float value) {
value = std::clamp(value, 0.0f, 1.0f);
Mutable().audioVolume = value;
+68
View File
@@ -1,18 +1,75 @@
#include "hle_stubs.h"
#include "memory.h"
#include "hle/controller_status_contract.h"
#include "input_bindings.h"
#include "wii_remote_input.h"
#include <algorithm>
#include <atomic>
#include <cstdio>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <SDL3/SDL_gamepad.h>
#include <dolphin/pad.h>
namespace {
std::atomic<bool> g_rumbleEnabled{true};
bool NativeButtonHeld(SDL_Gamepad* gamepad, uint32_t nativeButton) {
if (gamepad == nullptr || nativeButton == PAD_NATIVE_BUTTON_INVALID ||
nativeButton >= SDL_GAMEPAD_BUTTON_COUNT) {
return false;
}
return SDL_GetGamepadButton(gamepad, static_cast<SDL_GamepadButton>(nativeButton));
}
// A digital button bound to L or R has no analog travel of its own. On real
// hardware the click only engages at full depression, so report a full pull.
void FillTriggersHeldByButtons(PADStatus* statuses) {
if (InputBindings::InputBlocked()) {
return;
}
for (uint32_t port = 0; port < PAD_CHANMAX; ++port) {
if (statuses[port].err != PAD_ERR_NONE) {
continue;
}
const s32 index = PADGetIndexForPort(port);
if (index < 0) {
continue;
}
SDL_Gamepad* gamepad = PADGetSDLGamepadForIndex(static_cast<u32>(index));
if (gamepad == nullptr) {
continue;
}
const auto scan = [&](PADButtonMapping* mappings, u32 count) {
if (mappings == nullptr) {
return;
}
for (u32 i = 0; i < count; ++i) {
const PADButtonMapping& mapping = mappings[i];
if (mapping.padButton != PAD_TRIGGER_L && mapping.padButton != PAD_TRIGGER_R) {
continue;
}
if (!NativeButtonHeld(gamepad, mapping.nativeButton)) {
continue;
}
if (mapping.padButton == PAD_TRIGGER_L) {
statuses[port].triggerLeft = 255;
} else {
statuses[port].triggerRight = 255;
}
}
};
u32 count = 0;
scan(PADGetButtonMappings(port, &count), count);
count = 0;
scan(PADGetAltButtonMappings(port, &count), count);
}
}
void WritePadStatus(uint32_t base, const PADStatus& status) {
const auto guestStatus = PadStatusContract::Encode({
status.button,
@@ -32,6 +89,11 @@ void WritePadStatus(uint32_t base, const PADStatus& status) {
} // namespace
extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled)
{
g_rumbleEnabled.store(enabled, std::memory_order_relaxed);
}
extern "C" uint32_t PAD__Init_HLE()
{
return PADInit() ? 1u : 0u;
@@ -54,6 +116,9 @@ extern "C" uint32_t PAD__Read_HLE(uint32_t statusPtr)
// between "connected" and "no controller" every time the overlay toggles.
WiiRemoteInput::HideRemotesFromPad(statuses, PAD_CHANMAX);
FillTriggersHeldByButtons(statuses);
InputBindings::Apply(statuses);
try {
for (uint32_t i = 0; i < PAD_CHANMAX; ++i) {
WritePadStatus(statusPtr + static_cast<uint32_t>(i * PadStatusContract::kGuestStatusSize),
@@ -81,6 +146,9 @@ PPC_NATIVE_OVERRIDE(801AF1E4, PAD__Recalibrate_HLE, uint32_t, (uint32_t mask), (
extern "C" void PAD__ControlMotor_HLE(int32_t chan, uint32_t command)
{
if (command == PAD_MOTOR_RUMBLE && !g_rumbleEnabled.load(std::memory_order_relaxed)) {
command = PAD_MOTOR_STOP;
}
PADControlMotor(chan, command);
}
PPC_NATIVE_OVERRIDE_VOID(801AF908, PAD__ControlMotor_HLE, (int32_t chan, uint32_t command), (chan, command));
+302
View File
@@ -0,0 +1,302 @@
#include "input_bindings.h"
#include "controller_button_names.h"
#include "input_expr.h"
#include "runtime_config.h"
#include "runtime_log.h"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <mutex>
#include <unordered_map>
#include <SDL3/SDL_gamepad.h>
namespace InputBindings {
namespace {
struct Binding {
std::string text;
InputExpr::Expression expr;
bool active = false;
};
std::mutex g_mutex;
std::array<std::array<Binding, kControls.size()>, PAD_CHANMAX> g_bindings;
bool g_anyBound = false;
bool g_inputBlocked = false;
// Dolphin input names, mapped onto SDL. XInput-style names are exact; DInput
// "Button <n>" indices follow the common PlayStation layout, which is what
// DInput reports for a DualShock/DualSense. Other pads may number differently.
const std::unordered_map<std::string, SDL_GamepadButton>& ButtonNames() {
static const std::unordered_map<std::string, SDL_GamepadButton> table = {
{"Button A", SDL_GAMEPAD_BUTTON_SOUTH}, {"Button B", SDL_GAMEPAD_BUTTON_EAST},
{"Button X", SDL_GAMEPAD_BUTTON_WEST}, {"Button Y", SDL_GAMEPAD_BUTTON_NORTH},
{"Shoulder L", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER},
{"Shoulder R", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER},
{"Thumb L", SDL_GAMEPAD_BUTTON_LEFT_STICK}, {"Thumb R", SDL_GAMEPAD_BUTTON_RIGHT_STICK},
{"Start", SDL_GAMEPAD_BUTTON_START}, {"Back", SDL_GAMEPAD_BUTTON_BACK},
{"Guide", SDL_GAMEPAD_BUTTON_GUIDE},
{"Pad N", SDL_GAMEPAD_BUTTON_DPAD_UP}, {"Pad S", SDL_GAMEPAD_BUTTON_DPAD_DOWN},
{"Pad W", SDL_GAMEPAD_BUTTON_DPAD_LEFT}, {"Pad E", SDL_GAMEPAD_BUTTON_DPAD_RIGHT},
{"Hat 0 N", SDL_GAMEPAD_BUTTON_DPAD_UP}, {"Hat 0 S", SDL_GAMEPAD_BUTTON_DPAD_DOWN},
{"Hat 0 W", SDL_GAMEPAD_BUTTON_DPAD_LEFT}, {"Hat 0 E", SDL_GAMEPAD_BUTTON_DPAD_RIGHT},
{"Button 0", SDL_GAMEPAD_BUTTON_WEST}, {"Button 1", SDL_GAMEPAD_BUTTON_SOUTH},
{"Button 2", SDL_GAMEPAD_BUTTON_EAST}, {"Button 3", SDL_GAMEPAD_BUTTON_NORTH},
{"Button 4", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER},
{"Button 5", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER},
{"Button 8", SDL_GAMEPAD_BUTTON_BACK}, {"Button 9", SDL_GAMEPAD_BUTTON_START},
{"Button 10", SDL_GAMEPAD_BUTTON_LEFT_STICK},
{"Button 11", SDL_GAMEPAD_BUTTON_RIGHT_STICK},
{"Button 12", SDL_GAMEPAD_BUTTON_GUIDE}, {"Button 13", SDL_GAMEPAD_BUTTON_TOUCHPAD},
};
return table;
}
// Signed axis names: SDL axis plus the direction that counts as positive.
struct AxisRef {
SDL_GamepadAxis axis;
int sign;
};
const std::unordered_map<std::string, AxisRef>& AxisNames() {
static const std::unordered_map<std::string, AxisRef> table = {
{"Axis X-", {SDL_GAMEPAD_AXIS_LEFTX, -1}}, {"Axis X+", {SDL_GAMEPAD_AXIS_LEFTX, 1}},
{"Axis Y-", {SDL_GAMEPAD_AXIS_LEFTY, -1}}, {"Axis Y+", {SDL_GAMEPAD_AXIS_LEFTY, 1}},
{"Axis Z-", {SDL_GAMEPAD_AXIS_RIGHTX, -1}}, {"Axis Z+", {SDL_GAMEPAD_AXIS_RIGHTX, 1}},
{"Axis Zr-", {SDL_GAMEPAD_AXIS_RIGHTY, -1}},{"Axis Zr+", {SDL_GAMEPAD_AXIS_RIGHTY, 1}},
{"Left X-", {SDL_GAMEPAD_AXIS_LEFTX, -1}}, {"Left X+", {SDL_GAMEPAD_AXIS_LEFTX, 1}},
{"Left Y-", {SDL_GAMEPAD_AXIS_LEFTY, 1}}, {"Left Y+", {SDL_GAMEPAD_AXIS_LEFTY, -1}},
{"Right X-", {SDL_GAMEPAD_AXIS_RIGHTX, -1}},{"Right X+", {SDL_GAMEPAD_AXIS_RIGHTX, 1}},
{"Right Y-", {SDL_GAMEPAD_AXIS_RIGHTY, 1}}, {"Right Y+", {SDL_GAMEPAD_AXIS_RIGHTY, -1}},
{"Full Axis Xr+", {SDL_GAMEPAD_AXIS_LEFT_TRIGGER, 1}},
{"Full Axis Yr+", {SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, 1}},
{"Trigger L", {SDL_GAMEPAD_AXIS_LEFT_TRIGGER, 1}},
{"Trigger R", {SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, 1}},
};
return table;
}
double ReadInput(SDL_Gamepad* gamepad, const std::string& name) {
if (gamepad == nullptr) {
return 0.0;
}
if (const auto it = ButtonNames().find(name); it != ButtonNames().end()) {
return SDL_GetGamepadButton(gamepad, it->second) ? 1.0 : 0.0;
}
if (const auto it = AxisNames().find(name); it != AxisNames().end()) {
const double raw = SDL_GetGamepadAxis(gamepad, it->second.axis) / 32767.0;
return std::clamp(raw * it->second.sign, 0.0, 1.0);
}
// Fall back to this project's own positional names, so a binding written
// here does not have to use Dolphin vocabulary.
if (const auto* native = ControllerNames::FindNativeButton(name)) {
if (native->nativeButton != PAD_NATIVE_BUTTON_INVALID) {
return SDL_GetGamepadButton(gamepad, static_cast<SDL_GamepadButton>(native->nativeButton)) ? 1.0
: 0.0;
}
}
return 0.0;
}
SDL_Gamepad* GamepadForPort(uint32_t port) {
const s32 index = PADGetIndexForPort(port);
return index < 0 ? nullptr : PADGetSDLGamepadForIndex(static_cast<u32>(index));
}
size_t ControlIndexForDolphinName(const std::string& name) {
for (size_t i = 0; i < kControls.size(); ++i) {
if (name == kControls[i].dolphinName) {
return i;
}
}
return kControls.size();
}
void RecomputeAnyBoundLocked() {
g_anyBound = false;
for (const auto& port : g_bindings) {
for (const auto& binding : port) {
if (!binding.expr.Empty()) {
g_anyBound = true;
return;
}
}
}
}
std::string ConfigKey(uint32_t port, size_t control) {
std::string key = "expr_" + std::to_string(port + 1) + "_";
for (const char* c = kControls[control].dolphinName; *c != '\0'; ++c) {
key += (*c == '/' || *c == '-') ? '_' : static_cast<char>(std::tolower(*c));
}
return key;
}
} // namespace
void SetInputBlocked(bool blocked) noexcept {
std::lock_guard<std::mutex> lock(g_mutex);
g_inputBlocked = blocked;
}
bool InputBlocked() noexcept {
std::lock_guard<std::mutex> lock(g_mutex);
return g_inputBlocked;
}
void Reload() noexcept {
std::lock_guard<std::mutex> lock(g_mutex);
for (uint32_t port = 0; port < PAD_CHANMAX; ++port) {
for (size_t control = 0; control < kControls.size(); ++control) {
Binding& binding = g_bindings[port][control];
binding = Binding{};
binding.text = RuntimeConfigFile::ControllerExpression(ConfigKey(port, control));
std::string error;
if (!binding.text.empty() &&
!InputExpr::Expression::Parse(binding.text, binding.expr, error)) {
RT_LOG(RT_TAG_CONFIG) << "expression for port " << (port + 1) << " "
<< kControls[control].dolphinName << ": " << error << std::endl;
}
}
}
RecomputeAnyBoundLocked();
}
void Apply(PADStatus* statuses) noexcept {
if (statuses == nullptr) {
return;
}
std::lock_guard<std::mutex> lock(g_mutex);
if (!g_anyBound) {
return;
}
const bool blocked = g_inputBlocked;
for (uint32_t port = 0; port < PAD_CHANMAX; ++port) {
if (statuses[port].err != PAD_ERR_NONE) {
continue;
}
SDL_Gamepad* gamepad = GamepadForPort(port);
const InputExpr::InputSource source = [gamepad](const std::string& name) {
return ReadInput(gamepad, name);
};
for (size_t control = 0; control < kControls.size(); ++control) {
Binding& binding = g_bindings[port][control];
if (binding.expr.Empty()) {
continue;
}
if (blocked) {
binding.active = false;
continue;
}
const double value = binding.expr.Evaluate(source);
binding.active = value > InputExpr::kConditionThreshold;
const ControlInfo& info = kControls[control];
if (info.padButton != 0 && binding.active) {
statuses[port].button |= info.padButton;
}
if (info.analog != 0) {
const double safe = std::isfinite(value) ? std::clamp(value, 0.0, 1.0) : 0.0;
const auto scaled = static_cast<uint8_t>(safe * 255.0);
uint8_t& target =
info.analog == 1 ? statuses[port].triggerLeft : statuses[port].triggerRight;
target = std::max(target, scaled);
}
}
}
}
std::string GetExpression(uint32_t port, size_t control) noexcept {
if (port >= PAD_CHANMAX || control >= kControls.size()) {
return {};
}
std::lock_guard<std::mutex> lock(g_mutex);
return g_bindings[port][control].text;
}
bool SetExpression(uint32_t port, size_t control, const std::string& text, std::string& error) noexcept {
if (port >= PAD_CHANMAX || control >= kControls.size()) {
error = "invalid control";
return false;
}
InputExpr::Expression parsed;
if (!InputExpr::Expression::Parse(text, parsed, error)) {
return false;
}
{
std::lock_guard<std::mutex> lock(g_mutex);
Binding& binding = g_bindings[port][control];
binding.text = text;
binding.expr = std::move(parsed);
binding.active = false;
RecomputeAnyBoundLocked();
}
RuntimeConfigFile::SetControllerExpression(ConfigKey(port, control), text);
return true;
}
bool IsActive(uint32_t port, size_t control) noexcept {
if (port >= PAD_CHANMAX || control >= kControls.size()) {
return false;
}
std::lock_guard<std::mutex> lock(g_mutex);
return g_bindings[port][control].active;
}
std::string DefaultDolphinConfigPath() noexcept {
std::error_code ec;
if (const char* appdata = std::getenv("APPDATA"); appdata != nullptr) {
const std::filesystem::path roaming =
std::filesystem::path(appdata) / "Dolphin Emulator" / "Config" / "GCPadNew.ini";
if (std::filesystem::exists(roaming, ec)) {
return RuntimeConfigFile::PathToUtf8(roaming);
}
}
const auto executableDirectory = RuntimeConfigFile::ExecutableDirectory();
return RuntimeConfigFile::PathToUtf8(executableDirectory ? *executableDirectory / "GCPadNew.ini"
: std::filesystem::path("GCPadNew.ini"));
}
int ImportDolphinConfig(const std::string& path, int padIndex, uint32_t port, std::string& summary,
std::string& error) noexcept {
std::vector<std::pair<std::string, std::string>> controls;
std::string device;
if (!InputExpr::ReadDolphinConfig(RuntimeConfigFile::PathFromUtf8(path), padIndex, controls, device,
error)) {
return -1;
}
int imported = 0;
std::vector<std::string> skipped;
for (const auto& [name, text] : controls) {
const size_t control = ControlIndexForDolphinName(name);
if (control == kControls.size()) {
if (name.rfind("Main Stick/", 0) == 0 || name.rfind("C-Stick/", 0) == 0) {
skipped.push_back(name);
}
continue;
}
std::string parseError;
if (!SetExpression(port, control, text, parseError)) {
skipped.push_back(name);
RT_LOG(RT_TAG_CONFIG) << "import " << name << ": " << parseError << std::endl;
continue;
}
++imported;
}
summary = "Imported " + std::to_string(imported) + " controls";
if (!device.empty()) {
summary += " from " + device;
}
if (!skipped.empty()) {
summary += "; skipped " + std::to_string(skipped.size()) +
" (stick axes and unsupported inputs keep their existing mapping)";
}
return imported;
}
} // namespace InputBindings
+631
View File
@@ -0,0 +1,631 @@
#include "input_expr.h"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <fstream>
#include <unordered_map>
namespace InputExpr {
namespace {
using Clock = std::chrono::steady_clock;
using FSec = std::chrono::duration<double>;
enum class Kind {
Literal, Input, Not, Add, Sub, Mul, Div, And, Or, Xor,
Greater, Less, Equal,
FnIf, FnMin, FnMax, FnClamp, FnAbs, FnSqrt, FnPow, FnSin, FnCos, FnTan,
FnDeadzone, FnTimer, FnToggle, FnHold, FnTap, FnPulse, FnSmooth, FnNot,
};
struct FnInfo {
Kind kind;
int minArgs;
int maxArgs;
};
const std::unordered_map<std::string, FnInfo>& FunctionTable() {
static const std::unordered_map<std::string, FnInfo> table = {
{"not", {Kind::FnNot, 1, 1}}, {"if", {Kind::FnIf, 3, 3}},
{"min", {Kind::FnMin, 2, 2}}, {"max", {Kind::FnMax, 2, 2}},
{"clamp", {Kind::FnClamp, 3, 3}}, {"abs", {Kind::FnAbs, 1, 1}},
{"sqrt", {Kind::FnSqrt, 1, 1}}, {"pow", {Kind::FnPow, 2, 2}},
{"sin", {Kind::FnSin, 1, 1}}, {"cos", {Kind::FnCos, 1, 1}},
{"tan", {Kind::FnTan, 1, 1}}, {"deadzone", {Kind::FnDeadzone, 2, 2}},
{"timer", {Kind::FnTimer, 1, 1}}, {"toggle", {Kind::FnToggle, 1, 2}},
{"hold", {Kind::FnHold, 2, 2}}, {"tap", {Kind::FnTap, 2, 3}},
{"pulse", {Kind::FnPulse, 2, 2}}, {"smooth", {Kind::FnSmooth, 2, 3}},
};
return table;
}
} // namespace
struct Node {
Kind kind;
double literal = 0.0;
std::string input;
std::vector<std::unique_ptr<Node>> args;
// Per-instance state for the stateful functions. Mutable because Evaluate
// is logically a read of current input state.
mutable bool released = false;
mutable bool state = false;
mutable unsigned taps = 0;
mutable double value = 0.0;
mutable Clock::time_point mark = Clock::now();
mutable bool marked = false;
};
namespace {
// ---- tokenizer ----------------------------------------------------------
struct Token {
enum Type { End, Input, Number, Ident, Op, LParen, RParen, Comma } type = End;
std::string text;
};
class Lexer {
public:
explicit Lexer(const std::string& text) : m_text(text) {}
bool Next(Token& tok, std::string& error) {
while (m_pos < m_text.size() && std::isspace(static_cast<unsigned char>(m_text[m_pos]))) {
++m_pos;
}
if (m_pos >= m_text.size()) {
tok = Token{};
return true;
}
const char c = m_text[m_pos];
if (c == '`') {
const size_t close = m_text.find('`', m_pos + 1);
if (close == std::string::npos) {
error = "unterminated ` in expression";
return false;
}
tok.type = Token::Input;
tok.text = m_text.substr(m_pos + 1, close - m_pos - 1);
m_pos = close + 1;
return true;
}
if (std::isdigit(static_cast<unsigned char>(c)) || c == '.') {
size_t end = m_pos;
while (end < m_text.size() &&
(std::isdigit(static_cast<unsigned char>(m_text[end])) || m_text[end] == '.')) {
++end;
}
tok.type = Token::Number;
tok.text = m_text.substr(m_pos, end - m_pos);
m_pos = end;
return true;
}
if (std::isalpha(static_cast<unsigned char>(c)) || c == '_') {
size_t end = m_pos;
while (end < m_text.size() &&
(std::isalnum(static_cast<unsigned char>(m_text[end])) || m_text[end] == '_' ||
m_text[end] == ' ')) {
++end;
}
// Trailing spaces belong to the separator, not the identifier.
while (end > m_pos && m_text[end - 1] == ' ') {
--end;
}
tok.type = Token::Ident;
tok.text = m_text.substr(m_pos, end - m_pos);
m_pos = end;
return true;
}
if (c == '(') { tok.type = Token::LParen; ++m_pos; return true; }
if (c == ')') { tok.type = Token::RParen; ++m_pos; return true; }
if (c == ',') { tok.type = Token::Comma; ++m_pos; return true; }
if (std::string("!&|^+-*/><=").find(c) != std::string::npos) {
tok.type = Token::Op;
tok.text = std::string(1, c);
++m_pos;
return true;
}
error = std::string("unexpected character '") + c + "' in expression";
return false;
}
size_t Position() const { return m_pos; }
private:
const std::string& m_text;
size_t m_pos = 0;
};
// ---- parser -------------------------------------------------------------
using NodePtr = std::unique_ptr<Node>;
class Parser {
public:
explicit Parser(const std::string& text) : m_lexer(text) { Advance(); }
NodePtr ParseExpression(std::string& error) {
NodePtr node = ParseBinary(0, error);
if (!node) {
return nullptr;
}
if (m_failed) {
error = m_lexError;
return nullptr;
}
if (m_tok.type != Token::End) {
error = "unexpected trailing input in expression";
return nullptr;
}
return node;
}
private:
void Advance() {
if (!m_lexer.Next(m_tok, m_lexError)) {
m_tok = Token{};
m_failed = true;
}
}
static int Precedence(const std::string& op) {
if (op == "|") return 1;
if (op == "^") return 2;
if (op == "&") return 3;
if (op == ">" || op == "<" || op == "=") return 4;
if (op == "+" || op == "-") return 5;
if (op == "*" || op == "/") return 6;
return -1;
}
static Kind BinaryKind(const std::string& op) {
if (op == "|") return Kind::Or;
if (op == "^") return Kind::Xor;
if (op == "&") return Kind::And;
if (op == ">") return Kind::Greater;
if (op == "<") return Kind::Less;
if (op == "=") return Kind::Equal;
if (op == "+") return Kind::Add;
if (op == "-") return Kind::Sub;
if (op == "*") return Kind::Mul;
return Kind::Div;
}
NodePtr ParseBinary(int minPrec, std::string& error) {
NodePtr lhs = ParseUnary(error);
if (!lhs) {
return nullptr;
}
while (m_tok.type == Token::Op) {
const int prec = Precedence(m_tok.text);
if (prec < 0 || prec < minPrec) {
break;
}
const std::string op = m_tok.text;
Advance();
NodePtr rhs = ParseBinary(prec + 1, error);
if (!rhs) {
return nullptr;
}
auto node = std::make_unique<Node>();
node->kind = BinaryKind(op);
node->args.push_back(std::move(lhs));
node->args.push_back(std::move(rhs));
lhs = std::move(node);
}
return lhs;
}
NodePtr ParseUnary(std::string& error) {
if (m_failed) {
error = m_lexError;
return nullptr;
}
if (m_tok.type == Token::Op && (m_tok.text == "!" || m_tok.text == "-" || m_tok.text == "+")) {
const std::string op = m_tok.text;
Advance();
NodePtr inner = ParseUnary(error);
if (!inner) {
return nullptr;
}
if (op == "+") {
return inner;
}
auto node = std::make_unique<Node>();
if (op == "!") {
node->kind = Kind::Not;
node->args.push_back(std::move(inner));
} else {
node->kind = Kind::Sub;
auto zero = std::make_unique<Node>();
zero->kind = Kind::Literal;
node->args.push_back(std::move(zero));
node->args.push_back(std::move(inner));
}
return node;
}
return ParsePrimary(error);
}
NodePtr ParsePrimary(std::string& error) {
if (m_failed) {
error = m_lexError;
return nullptr;
}
switch (m_tok.type) {
case Token::Input: {
auto node = std::make_unique<Node>();
node->kind = Kind::Input;
node->input = m_tok.text;
Advance();
return node;
}
case Token::Number: {
auto node = std::make_unique<Node>();
node->kind = Kind::Literal;
node->literal = std::strtod(m_tok.text.c_str(), nullptr);
Advance();
return node;
}
case Token::LParen: {
Advance();
NodePtr inner = ParseBinary(0, error);
if (!inner) {
return nullptr;
}
if (m_tok.type != Token::RParen) {
error = "expected closing paren";
return nullptr;
}
Advance();
return inner;
}
case Token::Ident: {
const std::string name = m_tok.text;
Advance();
if (m_tok.type != Token::LParen) {
// A bare identifier is an input name, as Dolphin allows for
// simple cases such as "Start" or "LSHIFT".
auto node = std::make_unique<Node>();
node->kind = Kind::Input;
node->input = name;
return node;
}
const auto it = FunctionTable().find(name);
if (it == FunctionTable().end()) {
error = "unknown function '" + name + "'";
return nullptr;
}
Advance();
auto node = std::make_unique<Node>();
node->kind = it->second.kind;
if (m_tok.type != Token::RParen) {
while (true) {
NodePtr arg = ParseBinary(0, error);
if (!arg) {
return nullptr;
}
node->args.push_back(std::move(arg));
if (m_tok.type != Token::Comma) {
break;
}
Advance();
}
}
if (m_tok.type != Token::RParen) {
error = "expected closing paren after " + name + " arguments";
return nullptr;
}
Advance();
const int count = static_cast<int>(node->args.size());
if (count < it->second.minArgs || count > it->second.maxArgs) {
error = name + " takes " + std::to_string(it->second.minArgs) + " to " +
std::to_string(it->second.maxArgs) + " arguments";
return nullptr;
}
return node;
}
default:
error = "expected start of expression";
return nullptr;
}
}
Lexer m_lexer;
Token m_tok;
std::string m_lexError;
bool m_failed = false;
};
// ---- evaluator ----------------------------------------------------------
double Eval(const Node& node, const InputSource& source);
double Arg(const Node& node, size_t index, const InputSource& source) {
return Eval(*node.args[index], source);
}
double Eval(const Node& node, const InputSource& source) {
switch (node.kind) {
case Kind::Literal: return node.literal;
case Kind::Input: return source ? source(node.input) : 0.0;
case Kind::Not:
case Kind::FnNot: return 1.0 - Arg(node, 0, source);
case Kind::Add: return Arg(node, 0, source) + Arg(node, 1, source);
case Kind::Sub: return Arg(node, 0, source) - Arg(node, 1, source);
case Kind::Mul: return Arg(node, 0, source) * Arg(node, 1, source);
case Kind::Div: {
// Both sides are evaluated even when the divisor is zero: the left
// subtree may hold stateful functions that need their frame update.
const double lhs = Arg(node, 0, source);
const double rhs = Arg(node, 1, source);
return rhs == 0.0 ? 0.0 : lhs / rhs;
}
case Kind::And: return std::min(Arg(node, 0, source), Arg(node, 1, source));
case Kind::Or: return std::max(Arg(node, 0, source), Arg(node, 1, source));
case Kind::Xor: {
const double a = Arg(node, 0, source);
const double b = Arg(node, 1, source);
return std::max(std::min(a, 1.0 - b), std::min(b, 1.0 - a));
}
case Kind::Greater: return Arg(node, 0, source) > Arg(node, 1, source) ? 1.0 : 0.0;
case Kind::Less: return Arg(node, 0, source) < Arg(node, 1, source) ? 1.0 : 0.0;
case Kind::Equal: return Arg(node, 0, source) == Arg(node, 1, source) ? 1.0 : 0.0;
case Kind::FnIf:
return Arg(node, 0, source) > kConditionThreshold ? Arg(node, 1, source) : Arg(node, 2, source);
case Kind::FnMin: return std::min(Arg(node, 0, source), Arg(node, 1, source));
case Kind::FnMax: return std::max(Arg(node, 0, source), Arg(node, 1, source));
case Kind::FnClamp: {
const double v = Arg(node, 0, source);
double lo = Arg(node, 1, source);
double hi = Arg(node, 2, source);
if (lo > hi) {
std::swap(lo, hi);
}
return std::clamp(v, lo, hi);
}
case Kind::FnAbs: return std::abs(Arg(node, 0, source));
case Kind::FnSqrt: return std::sqrt(Arg(node, 0, source));
case Kind::FnPow: return std::pow(Arg(node, 0, source), Arg(node, 1, source));
case Kind::FnSin: return std::sin(Arg(node, 0, source));
case Kind::FnCos: return std::cos(Arg(node, 0, source));
case Kind::FnTan: return std::tan(Arg(node, 0, source));
case Kind::FnDeadzone: {
const double v = Arg(node, 0, source);
const double dz = std::clamp(Arg(node, 1, source), 0.0, 0.999);
return std::copysign(std::max(0.0, std::abs(v) - dz) / (1.0 - dz), v);
}
case Kind::FnTimer: {
const auto now = Clock::now();
if (!node.marked) {
node.mark = now;
node.marked = true;
}
const double period = Arg(node, 0, source);
double progress = std::chrono::duration_cast<FSec>(now - node.mark).count() / period;
if (!std::isfinite(progress) || progress < 0.0) {
progress = 0.0;
node.mark = now;
} else if (progress >= 1.0) {
const double resets = std::floor(progress);
node.mark += std::chrono::duration_cast<Clock::duration>(FSec(period * resets));
progress -= resets;
}
return progress;
}
case Kind::FnToggle: {
const double inner = Arg(node, 0, source);
if (inner < kConditionThreshold) {
node.released = true;
} else if (node.released) {
node.released = false;
node.state = !node.state;
}
if (node.args.size() == 2 && Arg(node, 1, source) > kConditionThreshold) {
node.state = false;
}
return node.state ? 1.0 : 0.0;
}
case Kind::FnHold: {
const auto now = Clock::now();
if (!node.marked) {
node.mark = now;
node.marked = true;
}
const double input = Arg(node, 0, source);
if (input < kConditionThreshold) {
node.state = false;
node.mark = now;
} else if (!node.state) {
if (std::chrono::duration_cast<FSec>(now - node.mark).count() >= Arg(node, 1, source)) {
node.state = true;
}
}
return node.state ? 1.0 : 0.0;
}
case Kind::FnTap: {
const auto now = Clock::now();
if (!node.marked) {
node.mark = now;
node.marked = true;
}
const double elapsed = std::chrono::duration_cast<FSec>(now - node.mark).count();
const double input = Arg(node, 0, source);
const bool timeUp = elapsed > Arg(node, 1, source);
// The count is user authored, so a negative or huge value must not
// reach the unsigned conversion.
double requested = node.args.size() == 3 ? Arg(node, 2, source) : 2.0;
if (!std::isfinite(requested)) {
requested = 2.0;
}
const auto desired = static_cast<unsigned>(std::clamp(requested + 0.5, 1.0, 64.0));
if (input < kConditionThreshold) {
node.released = true;
if (node.taps > 0 && timeUp) {
node.taps = 0;
}
return 0.0;
}
if (node.released) {
if (node.taps == 0) {
node.mark = now;
}
++node.taps;
node.released = false;
}
return desired == node.taps ? 1.0 : 0.0;
}
case Kind::FnPulse: {
const auto now = Clock::now();
const double input = Arg(node, 0, source);
if (input < kConditionThreshold) {
node.released = true;
} else if (node.released) {
node.released = false;
const double requested = Arg(node, 1, source);
const double safe = std::isfinite(requested) ? std::clamp(requested, 0.0, 3600.0) : 0.0;
const auto seconds = std::chrono::duration_cast<Clock::duration>(FSec(safe));
if (node.state) {
node.mark += seconds;
} else {
node.state = true;
node.mark = now + seconds;
}
}
if (node.state && now >= node.mark) {
node.state = false;
}
return node.state ? 1.0 : 0.0;
}
case Kind::FnSmooth: {
const auto now = Clock::now();
if (!node.marked) {
node.mark = now;
node.marked = true;
}
const double elapsed = std::chrono::duration_cast<FSec>(now - node.mark).count();
node.mark = now;
const double desired = Arg(node, 0, source);
const double up = Arg(node, 1, source);
const double down = node.args.size() == 3 ? Arg(node, 2, source) : up;
const double rate = (desired < node.value) ? down : up;
const double maxMove = elapsed / rate;
if (!std::isfinite(maxMove)) {
node.value = desired;
} else {
const double diff = desired - node.value;
node.value += std::copysign(std::min(maxMove, std::abs(diff)), diff);
}
return node.value;
}
}
return 0.0;
}
void Collect(const Node& node, std::vector<std::string>& out) {
if (node.kind == Kind::Input) {
if (std::find(out.begin(), out.end(), node.input) == out.end()) {
out.push_back(node.input);
}
}
for (const auto& arg : node.args) {
Collect(*arg, out);
}
}
std::string Trim(const std::string& text) {
const size_t begin = text.find_first_not_of(" \t\r\n");
if (begin == std::string::npos) {
return {};
}
return text.substr(begin, text.find_last_not_of(" \t\r\n") - begin + 1);
}
} // namespace
Expression::Expression() = default;
Expression::~Expression() = default;
Expression::Expression(Expression&&) noexcept = default;
Expression& Expression::operator=(Expression&&) noexcept = default;
bool Expression::Parse(const std::string& text, Expression& out, std::string& error) {
out.m_root.reset();
if (Trim(text).empty()) {
return true;
}
Parser parser(text);
NodePtr root = parser.ParseExpression(error);
if (!root) {
return false;
}
out.m_root = std::move(root);
return true;
}
double Expression::Evaluate(const InputSource& source) const {
if (m_root == nullptr) {
return 0.0;
}
const double value = Eval(*m_root, source);
return std::isfinite(value) ? value : 0.0;
}
std::vector<std::string> Expression::ReferencedInputs() const {
std::vector<std::string> out;
if (m_root) {
Collect(*m_root, out);
}
return out;
}
bool ReadDolphinConfig(const std::filesystem::path& path, int padIndex,
std::vector<std::pair<std::string, std::string>>& controls,
std::string& deviceName, std::string& error) {
std::ifstream file(path);
if (!file) {
error = "could not open " + path.string();
return false;
}
const std::string wanted = "[GCPad" + std::to_string(padIndex) + "]";
bool inSection = false;
bool found = false;
std::string line;
controls.clear();
deviceName.clear();
while (std::getline(file, line)) {
const std::string trimmed = Trim(line);
if (trimmed.empty() || trimmed[0] == '#' || trimmed[0] == ';') {
continue;
}
if (trimmed.front() == '[') {
inSection = trimmed == wanted;
found = found || inSection;
continue;
}
if (!inSection) {
continue;
}
const size_t eq = trimmed.find('=');
if (eq == std::string::npos) {
continue;
}
const std::string key = Trim(trimmed.substr(0, eq));
const std::string value = Trim(trimmed.substr(eq + 1));
if (key == "Device") {
deviceName = value;
} else if (!value.empty()) {
controls.emplace_back(key, value);
}
}
if (!found) {
error = wanted + " not found in " + path.string();
return false;
}
return true;
}
} // namespace InputExpr
+139 -86
View File
@@ -1,6 +1,8 @@
#include "settings_overlay.h"
#include "audio_backend.h"
#include "controller_button_names.h"
#include "controller_mapping_wizard.h"
#include "input_bindings.h"
#include "game_graphics_options.h"
#include "music_attenuation.h"
#include "runtime_config.h"
@@ -34,6 +36,8 @@
#endif
#include <dolphin/pad.h>
extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled);
#include <dolphin/vi.h>
#include <aurora/aurora.h>
#include <aurora/gfx.h>
@@ -65,6 +69,7 @@ const char* GraphicsApiDisplayName() {
}
bool g_topBarVisible = false;
bool g_rumbleEnabled = RuntimeConfigFile::RumbleEnabled(true);
int g_controllerPort = 0;
float g_resolutionScale = RuntimeConfigFile::ResolutionMultiplier(1.0f);
int g_audioVolumePercent = static_cast<int>(std::lround(RuntimeConfigFile::AudioVolume(1.0f) * 100.0f));
@@ -106,62 +111,9 @@ std::array<int32_t, PAD_MAX_CONTROLLERS> g_configuredControllerIndices = [] {
return indices;
}();
struct ControllerButtonItem {
const char* configKey;
const char* label;
PADButton padButton;
};
constexpr std::array<ControllerButtonItem, PAD_BUTTON_COUNT> kControllerButtons = {{
{"a", "A", PAD_BUTTON_A},
{"b", "B", PAD_BUTTON_B},
{"x", "X", PAD_BUTTON_X},
{"y", "Y", PAD_BUTTON_Y},
{"start", "Start", PAD_BUTTON_START},
{"z", "Z", PAD_TRIGGER_Z},
{"l", "L", PAD_TRIGGER_L},
{"r", "R", PAD_TRIGGER_R},
{"up", "D-pad Up", PAD_BUTTON_UP},
{"down", "D-pad Down", PAD_BUTTON_DOWN},
{"left", "D-pad Left", PAD_BUTTON_LEFT},
{"right", "D-pad Right", PAD_BUTTON_RIGHT},
}};
struct NativeButtonItem {
const char* configName;
const char* label;
uint32_t nativeButton;
};
constexpr std::array<NativeButtonItem, SDL_GAMEPAD_BUTTON_COUNT + 1> kNativeButtons = {{
{"unmapped", "Unmapped / analog trigger", PAD_NATIVE_BUTTON_INVALID},
{"south", "South (A / Cross)", SDL_GAMEPAD_BUTTON_SOUTH},
{"east", "East (B / Circle)", SDL_GAMEPAD_BUTTON_EAST},
{"west", "West (X / Square)", SDL_GAMEPAD_BUTTON_WEST},
{"north", "North (Y / Triangle)", SDL_GAMEPAD_BUTTON_NORTH},
{"back", "Back / Select", SDL_GAMEPAD_BUTTON_BACK},
{"guide", "Guide / Home", SDL_GAMEPAD_BUTTON_GUIDE},
{"start", "Start / Options", SDL_GAMEPAD_BUTTON_START},
{"left_stick", "Left stick click", SDL_GAMEPAD_BUTTON_LEFT_STICK},
{"right_stick", "Right stick click", SDL_GAMEPAD_BUTTON_RIGHT_STICK},
{"left_shoulder", "Left shoulder", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER},
{"right_shoulder", "Right shoulder", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER},
{"dpad_up", "D-pad Up", SDL_GAMEPAD_BUTTON_DPAD_UP},
{"dpad_down", "D-pad Down", SDL_GAMEPAD_BUTTON_DPAD_DOWN},
{"dpad_left", "D-pad Left", SDL_GAMEPAD_BUTTON_DPAD_LEFT},
{"dpad_right", "D-pad Right", SDL_GAMEPAD_BUTTON_DPAD_RIGHT},
{"misc1", "Misc 1 / Share", SDL_GAMEPAD_BUTTON_MISC1},
{"right_paddle1", "Right paddle 1", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1},
{"left_paddle1", "Left paddle 1", SDL_GAMEPAD_BUTTON_LEFT_PADDLE1},
{"right_paddle2", "Right paddle 2", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2},
{"left_paddle2", "Left paddle 2", SDL_GAMEPAD_BUTTON_LEFT_PADDLE2},
{"touchpad", "Touchpad", SDL_GAMEPAD_BUTTON_TOUCHPAD},
{"misc2", "Misc 2", SDL_GAMEPAD_BUTTON_MISC2},
{"misc3", "Misc 3 / GC L click", SDL_GAMEPAD_BUTTON_MISC3},
{"misc4", "Misc 4 / GC R click", SDL_GAMEPAD_BUTTON_MISC4},
{"misc5", "Misc 5", SDL_GAMEPAD_BUTTON_MISC5},
{"misc6", "Misc 6", SDL_GAMEPAD_BUTTON_MISC6},
}};
using ControllerNames::kNativeButtons;
using ControllerNames::NativeButtonItem;
constexpr const auto& kControllerButtons = ControllerNames::kGameCubeButtons;
// Classic Controller Pro layout, indexed like kControllerButtons: the SNES-style
// diamond (A right, B bottom, X top, Y left) with digital bumpers driving the GC
@@ -178,6 +130,13 @@ constexpr std::array<const char*, PAD_BUTTON_COUNT> kClassicProPreset = {
"dpad_up", "dpad_down", "dpad_left", "dpad_right",
};
// PlayStation layout: bumpers drive the GC triggers, Z moves to Create/Share.
constexpr std::array<const char*, PAD_BUTTON_COUNT> kPlayStationPreset = {
"south", "east", "west", "north", "start", "back",
"left_shoulder", "right_shoulder",
"dpad_up", "dpad_down", "dpad_left", "dpad_right",
};
struct ResolutionItem {
const char* label;
float scale;
@@ -225,43 +184,25 @@ void LimitResolutionForFrameRate() {
}
}
const NativeButtonItem* FindNativeButton(std::string value) {
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), [&](const NativeButtonItem& item) {
return value == item.configName;
});
return it == kNativeButtons.end() ? nullptr : &*it;
}
using ControllerNames::FindNativeButton;
struct ControllerBindingPair {
std::string primary;
std::string secondary;
};
std::string TrimBindingToken(const std::string& token) {
const size_t begin = token.find_first_not_of(" \t");
if (begin == std::string::npos) {
return {};
}
const size_t end = token.find_last_not_of(" \t");
return token.substr(begin, end - begin + 1);
}
// Config values hold up to two comma-separated button names ("dpad_up" or
// "dpad_up,left_shoulder"); pressing either one counts as the GC button.
ControllerBindingPair SplitControllerBinding(const std::string& value) {
const size_t comma = value.find(',');
if (comma == std::string::npos) {
return {TrimBindingToken(value), {}};
return {ControllerNames::TrimToken(value), {}};
}
return {TrimBindingToken(value.substr(0, comma)), TrimBindingToken(value.substr(comma + 1))};
return {ControllerNames::TrimToken(value.substr(0, comma)), ControllerNames::TrimToken(value.substr(comma + 1))};
}
const NativeButtonItem& NativeButtonForValue(uint32_t nativeButton) {
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), [&](const NativeButtonItem& item) {
return nativeButton == item.nativeButton;
});
return it == kNativeButtons.end() ? kNativeButtons.front() : *it;
}
using ControllerNames::NativeButtonForValue;
void SetTopBarVisible(bool visible) {
if (g_topBarVisible == visible) {
@@ -393,10 +334,8 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) {
}
ImGui::EndDisabled();
ImGui::SameLine();
if (WiiRemoteInput::IsScanning() && WiiRemoteInput::PeriodicRescanEnabled()) {
if (WiiRemoteInput::IsScanning()) {
ImGui::TextDisabled("Scanning... (%u so far) - press 1+2 on the remote", WiiRemoteInput::ScanCount());
} else if (WiiRemoteInput::IsScanning()) {
ImGui::TextDisabled("Waiting for a remote - press 1+2 on the remote");
} else {
ImGui::TextDisabled("Not scanning");
}
@@ -457,6 +396,105 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) {
}
// Controller settings menu: port selection, controller assignment and button mapping.
int ExpressionResizeCallback(ImGuiInputTextCallbackData* data) {
if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) {
auto* text = static_cast<std::string*>(data->UserData);
text->resize(static_cast<size_t>(data->BufTextLen));
data->Buf = text->data();
}
return 0;
}
void DrawExpressionSettings() {
ImGui::SeparatorText("Expressions (Dolphin syntax)");
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 440.0f);
ImGui::TextDisabled(
"Optional. An expression overrides nothing: its result is combined with the "
"button mapping above. Operators ! & | ^ and functions if, min, max, clamp, "
"timer, toggle, hold, tap, pulse, smooth, deadzone behave as they do in Dolphin.");
ImGui::PopTextWrapPos();
static std::array<std::string, InputBindings::kControls.size()> errors;
static std::array<std::string, InputBindings::kControls.size()> buffers;
static std::string importStatus;
static int loadedPort = -1;
static bool reloadBuffers = true;
const auto port = static_cast<uint32_t>(g_controllerPort);
if (loadedPort != g_controllerPort || reloadBuffers) {
for (size_t i = 0; i < buffers.size(); ++i) {
buffers[i] = InputBindings::GetExpression(port, i);
}
errors.fill(std::string());
loadedPort = g_controllerPort;
reloadBuffers = false;
}
if (ImGui::Button("Import from Dolphin")) {
const std::string path = InputBindings::DefaultDolphinConfigPath();
std::string summary;
std::string error;
if (InputBindings::ImportDolphinConfig(path, g_controllerPort + 1, port, summary, error) < 0) {
importStatus = error;
} else {
importStatus = summary;
errors.fill(std::string());
reloadBuffers = true;
}
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Reads [GCPad%d] from %%APPDATA%%\\Dolphin Emulator\\Config\\GCPadNew.ini,\n"
"or GCPadNew.ini next to the executable.", g_controllerPort + 1);
}
if (!importStatus.empty()) {
ImGui::TextDisabled("%s", importStatus.c_str());
}
for (size_t i = 0; i < InputBindings::kControls.size(); ++i) {
ImGui::PushID(static_cast<int>(i) + 2000);
std::string& text = buffers[i];
ImGui::SetNextItemWidth(300.0f);
if (ImGui::InputText(InputBindings::kControls[i].label, text.data(), text.capacity() + 1,
ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackResize,
ExpressionResizeCallback, &text)) {
std::string error;
errors[i] = InputBindings::SetExpression(port, i, text, error) ? std::string() : error;
}
if (InputBindings::IsActive(port, i)) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f), "active");
}
if (!errors[i].empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.3f, 1.0f), "%s", errors[i].c_str());
}
ImGui::PopID();
}
}
void DrawRumbleSettings() {
ImGui::SeparatorText("Vibration");
if (ImGui::Checkbox("Controller vibration", &g_rumbleEnabled)) {
PAD_HLE_SetRumbleEnabled(g_rumbleEnabled);
RuntimeConfigFile::SetRumbleEnabled(g_rumbleEnabled);
if (!g_rumbleEnabled) {
// Stop whatever is already running: the game will not send another
// motor command until its own state machine decides to.
constexpr std::array<uint32_t, PAD_MAX_CONTROLLERS> stopAll{
PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD,
};
PADControlAllMotors(stopAll.data());
#if defined(_WIN32)
for (uint32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
Wup028Adapter::SetRumble(port, false);
}
#endif
}
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Applies to every port.");
}
}
void DrawControllerSettings() {
for (int port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
const std::string label = "Port " + std::to_string(port + 1);
@@ -545,20 +583,28 @@ void DrawControllerSettings() {
PADSerializeMappings();
mappings = PADGetButtonMappings(port, &mappingCount);
}
ImGui::SameLine();
if (ImGui::Button("Classic Controller Pro")) {
const auto applyPreset = [&](const std::array<const char*, PAD_BUTTON_COUNT>& preset) {
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
for (size_t i = 0; i < kControllerButtons.size(); ++i) {
if (const NativeButtonItem* native = FindNativeButton(kClassicProPreset[i])) {
if (const NativeButtonItem* native = FindNativeButton(preset[i])) {
PADSetButtonMapping(port, PADButtonMapping{native->nativeButton, kControllerButtons[i].padButton});
PADSetAltButtonMapping(port,
PADButtonMapping{PAD_NATIVE_BUTTON_INVALID, kControllerButtons[i].padButton});
RuntimeConfigFile::SetControllerButton(i, kClassicProPreset[i]);
RuntimeConfigFile::SetControllerButton(i, preset[i]);
}
}
altRowExpanded.fill(false);
PADSerializeMappings();
mappings = PADGetButtonMappings(port, &mappingCount);
};
ImGui::SameLine();
if (ImGui::Button("Classic Controller Pro")) {
applyPreset(kClassicProPreset);
}
ImGui::SameLine();
if (ImGui::Button("PlayStation")) {
applyPreset(kPlayStationPreset);
}
ImGui::SeparatorText("Button mapping");
@@ -640,6 +686,8 @@ void DrawControllerSettings() {
ImGui::TextUnformatted(kControllerButtons[i].label);
ImGui::PopID();
}
DrawExpressionSettings();
DrawRumbleSettings();
}
void DrawAudioSettings() {
@@ -994,6 +1042,8 @@ void PersistDisplayModeIfChanged() {
} // namespace
void InitializeRuntimeSettings() noexcept {
PAD_HLE_SetRumbleEnabled(g_rumbleEnabled);
InputBindings::Reload();
controller_mapping_wizard::LoadPersistedMappings();
ApplyConfiguredMappings();
AudioBackend::Instance().SetMasterVolume(static_cast<float>(g_audioVolumePercent) / 100.0f);
@@ -1014,6 +1064,7 @@ void InitializeRuntimeSettings() noexcept {
g_strapInputAccepted.store(false, std::memory_order_relaxed);
g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed);
PADBlockInput(false);
InputBindings::SetInputBlocked(false);
}
void HandleEvents(const AuroraEvent* events) noexcept {
@@ -1056,7 +1107,9 @@ void Draw() noexcept {
DrawTopBar();
controller_mapping_wizard::Draw();
// The wizard captures raw presses; keep them out of the game.
PADBlockInput(controller_mapping_wizard::IsActive());
const bool inputBlocked = controller_mapping_wizard::IsActive();
PADBlockInput(inputBlocked);
InputBindings::SetInputBlocked(inputBlocked);
DrawStartupScreen();
}
+219
View File
@@ -0,0 +1,219 @@
// Verifies the expression engine against Dolphin's documented semantics,
// including the exact line from the user's GCPadNew.ini.
#include "input_expr.h"
#include <chrono>
#include <cmath>
#include <cstdio>
#include <map>
#include <string>
#include <thread>
static int g_failures = 0;
static std::map<std::string, double> g_inputs;
static InputExpr::InputSource Source() {
return [](const std::string& name) {
const auto it = g_inputs.find(name);
return it == g_inputs.end() ? 0.0 : it->second;
};
}
static void Check(bool ok, const std::string& what) {
if (!ok) {
std::printf(" FAIL: %s\n", what.c_str());
++g_failures;
}
}
static InputExpr::Expression Compile(const std::string& text) {
InputExpr::Expression expr;
std::string error;
if (!InputExpr::Expression::Parse(text, expr, error)) {
std::printf(" FAIL: parse '%s': %s\n", text.c_str(), error.c_str());
++g_failures;
}
return expr;
}
static bool Pressed(const InputExpr::Expression& e) {
return e.Evaluate(Source()) > InputExpr::kConditionThreshold;
}
static void Sleep(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
int main() {
std::printf("Dolphin expression engine\n");
// Operators: & is min, | is max, ! is 1-x, matching Dolphin.
g_inputs["A"] = 1.0;
g_inputs["B"] = 0.0;
Check(Pressed(Compile("`A`")), "bare input");
Check(!Pressed(Compile("!`A`")), "not");
Check(!Pressed(Compile("`A` & `B`")), "and is min");
Check(Pressed(Compile("`A` | `B`")), "or is max");
Check(Pressed(Compile("`A` ^ `B`")), "xor");
Check(!Pressed(Compile("`A` ^ `A`")), "xor of equal inputs is false");
// Precedence: & binds tighter than |, so this is A | (B & A).
g_inputs["B"] = 0.0;
Check(Pressed(Compile("`A` | `B` & `A`")), "& binds tighter than |");
// Parens and numeric literals.
Check(Pressed(Compile("(`B` | 1)")), "literal");
Check(Pressed(Compile("min(1, `A`)")), "min");
Check(!Pressed(Compile("min(0, `A`)")), "min with zero");
Check(Pressed(Compile("if(`A`, 1, 0)")), "if");
Check(Pressed(Compile("clamp(5, 0, 1)")), "clamp");
// toggle flips on each rising edge and holds between them.
auto toggle = Compile("toggle(`T`)");
g_inputs["T"] = 0.0;
toggle.Evaluate(Source());
g_inputs["T"] = 1.0;
Check(Pressed(toggle), "toggle on after first press");
g_inputs["T"] = 0.0;
Check(Pressed(toggle), "toggle stays on after release");
g_inputs["T"] = 1.0;
Check(!Pressed(toggle), "toggle off on second press");
// hold requires the input to be down for the full duration.
auto hold = Compile("hold(`H`, 0.05)");
g_inputs["H"] = 1.0;
Check(!Pressed(hold), "hold not satisfied immediately");
Sleep(70);
Check(Pressed(hold), "hold satisfied after the interval");
g_inputs["H"] = 0.0;
Check(!Pressed(hold), "hold clears on release");
// pulse fires for the given duration after a rising edge.
auto pulse = Compile("pulse(`P`, 0.05)");
g_inputs["P"] = 0.0;
pulse.Evaluate(Source());
g_inputs["P"] = 1.0;
Check(Pressed(pulse), "pulse fires on rising edge");
Sleep(80);
Check(!Pressed(pulse), "pulse expires");
// The timing-window idiom seen in shared Dolphin configs.
auto window = Compile("!pulse(`W`, 0.05) & pulse(`W`, 0.15)");
g_inputs["W"] = 0.0;
window.Evaluate(Source());
g_inputs["W"] = 1.0;
Check(!Pressed(window), "window closed before its start");
Sleep(90);
Check(Pressed(window), "window open between the two pulses");
Sleep(90);
Check(!Pressed(window), "window closed after its end");
// timer ramps 0..1 and wraps, so a threshold turns it into a square wave.
auto timer = Compile("`X` & timer(0.1)");
g_inputs["X"] = 1.0;
int high = 0;
int low = 0;
for (int i = 0; i < 40; ++i) {
(Pressed(timer) ? high : low)++;
Sleep(5);
}
Check(high > 5 && low > 5, "timer alternates high and low");
// The exact D-Pad/Up line from the user's GCPadNew.ini.
auto dolphinLine = Compile("`Hat 0 N` | `Button 4` & timer(0.01)");
g_inputs["Hat 0 N"] = 0.0;
g_inputs["Button 4"] = 0.0;
Check(!Pressed(dolphinLine), "idle with nothing held");
g_inputs["Hat 0 N"] = 1.0;
Check(Pressed(dolphinLine), "hat alone presses");
g_inputs["Hat 0 N"] = 0.0;
g_inputs["Button 4"] = 1.0;
high = low = 0;
for (int i = 0; i < 60; ++i) {
(Pressed(dolphinLine) ? high : low)++;
Sleep(2);
}
Check(high > 5 && low > 5, "LB alternates via timer(0.01)");
// Regression tests for the CodeRabbit findings on PR #89.
g_inputs["A"] = 1.0;
// clamp with reversed bounds: std::clamp is UB when lo > hi.
Check(Compile("clamp(0.5, 1, 0)").Evaluate(Source()) == 0.5, "clamp tolerates reversed bounds");
// deadzone(v, 1) would divide by zero.
{
const double v = Compile("deadzone(`A`, 1)").Evaluate(Source());
Check(std::isfinite(v), "deadzone with dz=1 stays finite");
}
// timer with a zero or negative period would produce inf or NaN.
for (const char* text : {"timer(0)", "timer(-1)"}) {
const double v = Compile(text).Evaluate(Source());
Check(std::isfinite(v), std::string(text) + " stays finite");
}
// Any non-finite result is squashed before it can reach the uint8_t cast.
for (const char* text : {"sqrt(0 - 1)", "pow(10, 10000)", "tan(1.5707963267948966)"}) {
const double v = Compile(text).Evaluate(Source());
Check(std::isfinite(v), std::string(text) + " is sanitised at the boundary");
}
// tap count is user authored; negative, huge and non-finite must not reach
// the unsigned conversion.
for (const char* text : {"tap(`A`, 0.2, -1)", "tap(`A`, 0.2, 999999999)", "tap(`A`, 0.2, 0)"}) {
InputExpr::Expression e;
std::string err;
Check(InputExpr::Expression::Parse(text, e, err), std::string("parse ") + text);
const double v = e.Evaluate(Source());
Check(std::isfinite(v), std::string(text) + " evaluates without UB");
}
// Exponent notation is not part of the number syntax, matching Dolphin's
// lexer; it is rejected rather than silently misparsed.
{
InputExpr::Expression e;
std::string err;
Check(!InputExpr::Expression::Parse("tap(`A`, 0.2, 1e30)", e, err), "exponent notation rejected");
}
// A zero divisor must not skip the left subtree: stateful functions there
// still need their per-frame update.
{
auto divToggle = Compile("toggle(`D`) / `Z`");
g_inputs["Z"] = 0.0;
g_inputs["D"] = 0.0;
divToggle.Evaluate(Source());
g_inputs["D"] = 1.0;
divToggle.Evaluate(Source()); // rising edge seen even though rhs is 0
g_inputs["D"] = 0.0;
g_inputs["Z"] = 1.0;
Check(divToggle.Evaluate(Source()) > InputExpr::kConditionThreshold,
"toggle still latched while the divisor was zero");
}
// smooth with a zero rate divides 0 by 0; NaN must not stick in the node.
{
auto sm = Compile("smooth(`A`, 0)");
g_inputs["A"] = 1.0;
sm.Evaluate(Source());
Sleep(5);
Check(std::isfinite(sm.Evaluate(Source())), "smooth with a zero rate stays finite");
}
// Referenced inputs, used for diagnostics in the UI.
const auto refs = dolphinLine.ReferencedInputs();
Check(refs.size() == 2, "two referenced inputs");
// Errors are reported, not silently swallowed.
InputExpr::Expression bad;
std::string error;
Check(!InputExpr::Expression::Parse("`A` & ", bad, error), "trailing operator rejected");
Check(!InputExpr::Expression::Parse("nope(1)", bad, error), "unknown function rejected");
Check(!InputExpr::Expression::Parse("(`A`", bad, error), "missing paren rejected");
Check(!InputExpr::Expression::Parse("`A", bad, error), "unterminated backtick rejected");
Check(InputExpr::Expression::Parse("", bad, error) && bad.Empty(), "empty parses to empty");
Check(!InputExpr::Expression::Parse("hold(`A`)", bad, error), "wrong arg count rejected");
if (g_failures == 0) {
std::printf("all checks passed\n");
return 0;
}
std::printf("%d check(s) failed\n", g_failures);
return 1;
}