mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-09-12 17:45:52 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c16f1533e5 | |||
| 8c6c177857 | |||
| be153e0fa0 | |||
| e34f055b3a | |||
| 602348f905 |
@@ -34,3 +34,34 @@ jobs:
|
|||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: dotnet test translator/Translator.sln -c Release --no-build --verbosity normal
|
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
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
name: Package installers
|
name: Package installers
|
||||||
|
|
||||||
# Builds the per-platform installer/setup tool (WiiCompiled-Setup.exe /
|
# Builds the per-platform installer/setup tool (WiiCompiled-Setup.exe /
|
||||||
# WiiCompiled-Setup-x86_64.AppImage) via Launcher/Build-Installer.ps1 and
|
# WiiCompiled-Setup-x86_64.AppImage / WiiCompiled-Setup.pkg) via the platform packaging scripts -
|
||||||
# Launcher/build-appimage.sh respectively - the same scripts a maintainer runs by hand today to
|
# the same scripts a maintainer runs by hand today to
|
||||||
# produce a GitHub Release asset. This does NOT build the actual translated game executable:
|
# 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),
|
# 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.
|
# which is proprietary and not present in this repository or in CI.
|
||||||
@@ -11,6 +11,11 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- '*'
|
- '*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Package version
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -86,14 +91,99 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
archive: false
|
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
|
# 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
|
# 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
|
# (non-prerelease) release whose tag is v<semver>, carrying the expected platform assets,
|
||||||
# WiiCompiled-Setup.exe, produced by a setup host that reports that same version.
|
# produced by setup hosts that report that same version.
|
||||||
release:
|
release:
|
||||||
name: Publish GitHub Release
|
name: Publish GitHub Release
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
needs: [linux-appimage, windows-installer]
|
needs: [linux-appimage, windows-installer, macos-setup-package]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -139,7 +229,7 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ls -lR artifacts
|
ls -lR artifacts
|
||||||
assets=()
|
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)"
|
found="$(find artifacts -type f -name "$name" | head -n 1)"
|
||||||
[ -n "$found" ] && [ -s "$found" ] || { echo "::error::missing release asset $name"; exit 1; }
|
[ -n "$found" ] && [ -s "$found" ] || { echo "::error::missing release asset $name"; exit 1; }
|
||||||
assets+=("$found")
|
assets+=("$found")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace WiiCompiled.Setup.Linux;
|
|||||||
internal static class ProductInfo
|
internal static class ProductInfo
|
||||||
{
|
{
|
||||||
public const string Name = "WiiCompiled";
|
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>
|
/// <summary>One installed product's record inside install-state.json.</summary>
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ internal sealed class InstallerEngine
|
|||||||
var existing = new Installation(installDirectory);
|
var existing = new Installation(installDirectory);
|
||||||
|
|
||||||
PortableInstallHealing.HealMovedInstall(existing, _reporter);
|
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();
|
var previousState = existing.ReadInstallState();
|
||||||
using var scratch = Directory.Exists(installDirectory)
|
using var scratch = Directory.Exists(installDirectory)
|
||||||
? InstallScratchSpace.CreateInsideInstall(installDirectory, _reporter)
|
? InstallScratchSpace.CreateInsideInstall(installDirectory, _reporter)
|
||||||
@@ -139,7 +142,7 @@ internal sealed class InstallerEngine
|
|||||||
updatedState.RetroRewindInstalled, candidateRuntimeAssetsFingerprint,
|
updatedState.RetroRewindInstalled, candidateRuntimeAssetsFingerprint,
|
||||||
remainingCancellation);
|
remainingCancellation);
|
||||||
Publish(staging, installDirectory, canonicalRetroRoot, updatedState,
|
Publish(staging, installDirectory, canonicalRetroRoot, updatedState,
|
||||||
releaseEntries, remainingCancellation);
|
releaseEntries, remainingCancellation, createShortcuts: firstInstall);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +164,8 @@ internal sealed class InstallerEngine
|
|||||||
|
|
||||||
await PublishToolkitAndReconcileProductsAsync(existing, staging, workspace, manifest,
|
await PublishToolkitAndReconcileProductsAsync(existing, staging, workspace, manifest,
|
||||||
previousState, options, canonicalRetroRoot, retroCompileInputs,
|
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,
|
string stagedWorkspace, PayloadManifest manifest, InstallState? previousState,
|
||||||
InstallOptions options, string? canonicalRetroRoot,
|
InstallOptions options, string? canonicalRetroRoot,
|
||||||
RetroRewindCompileInputs? retroCompileInputs,
|
RetroRewindCompileInputs? retroCompileInputs,
|
||||||
bool publishGameAssets, CancellationToken cancellationToken)
|
bool publishGameAssets, bool createShortcuts, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var installDirectory = existing.Root;
|
var installDirectory = existing.Root;
|
||||||
|
|
||||||
@@ -214,7 +218,8 @@ internal sealed class InstallerEngine
|
|||||||
if (publishGameAssets) AddComponent(entries, staging, installDirectory, "GameAssets");
|
if (publishGameAssets) AddComponent(entries, staging, installDirectory, "GameAssets");
|
||||||
|
|
||||||
Publish(staging, installDirectory, canonicalRetroRoot, state,
|
Publish(staging, installDirectory, canonicalRetroRoot, state,
|
||||||
entries, cancellationToken, progressPercent: 8, completionPercent: 10);
|
entries, cancellationToken, progressPercent: 8, completionPercent: 10,
|
||||||
|
createShortcuts: createShortcuts);
|
||||||
|
|
||||||
_reporter.Progress(InstallStages.BuildBase,
|
_reporter.Progress(InstallStages.BuildBase,
|
||||||
"Producing the installed products with the published toolkit...", 11);
|
"Producing the installed products with the published toolkit...", 11);
|
||||||
@@ -277,7 +282,7 @@ internal sealed class InstallerEngine
|
|||||||
private void Publish(string staging, string installDirectory,
|
private void Publish(string staging, string installDirectory,
|
||||||
string? canonicalRetroRoot, InstallState state,
|
string? canonicalRetroRoot, InstallState state,
|
||||||
List<InstallTransactionEntry> entries, CancellationToken cancellationToken,
|
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"),
|
entries.Add(InstallTransactionEntry.Directory(Path.Combine(staging, "licenses"),
|
||||||
Path.Combine(installDirectory, "licenses")));
|
Path.Combine(installDirectory, "licenses")));
|
||||||
@@ -322,7 +327,8 @@ internal sealed class InstallerEngine
|
|||||||
{
|
{
|
||||||
ShellIntegration.RegisterUninstaller(installDirectory, state.RetroRewindInstalled);
|
ShellIntegration.RegisterUninstaller(installDirectory, state.RetroRewindInstalled);
|
||||||
}
|
}
|
||||||
ShellIntegration.CreateShortcuts(installDirectory);
|
if (createShortcuts)
|
||||||
|
ShellIntegration.CreateShortcuts(installDirectory);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 emit-build-shards 'Preparing native build shards'; translator "${args[@]}"
|
||||||
|
|
||||||
step configure-native 'Configuring the native toolchain'
|
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)
|
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"
|
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
|
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
|
||||||
|
|||||||
@@ -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/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"
|
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
|
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"
|
mkdir -p "$resources/tools/cmake"
|
||||||
copy_clean "$nodtool" "$resources/tools/nodtool"; chmod +x "$resources/tools/nodtool"
|
copy_clean "$nodtool" "$resources/tools/nodtool"; chmod +x "$resources/tools/nodtool"
|
||||||
copy_clean "$translator" "$resources/tools/Translator.Cli"; chmod +x "$resources/tools/Translator.Cli"
|
copy_clean "$translator" "$resources/tools/Translator.Cli"; chmod +x "$resources/tools/Translator.Cli"
|
||||||
|
|||||||
@@ -62,10 +62,30 @@ if ! /usr/bin/xcode-select -p >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$support_root" "$products"
|
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'
|
printf 'Preparing the local build workspace...\n'
|
||||||
rm -rf "$workspace"
|
if [[ ! -d "$workspace" ]]; then
|
||||||
/usr/bin/ditto "$workspace_source" "$workspace"
|
/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
|
fi
|
||||||
|
|
||||||
profile=base
|
profile=base
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ Press **F10** while the game window has focus:
|
|||||||
- Internal resolution
|
- Internal resolution
|
||||||
- FPS counter
|
- FPS counter
|
||||||
- Controller assignment for all four ports
|
- 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
|
- Volume, instant mute, and the music ducking toggle
|
||||||
|
|
||||||
Everything you change is saved to `Config.toml` on the spot and restored next launch.
|
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
|
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
|
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.
|
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
|
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).
|
adapter must be switched to the WinUSB driver once (Zadig).
|
||||||
|
|
||||||
**Real Wii Remotes over Bluetooth.**
|
**Real Wii Remotes over Bluetooth.**
|
||||||
Pair a Wii Remote with Windows (Settings > Bluetooth > Add device, press 1+2 or SYNC, leave the
|
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
|
PIN empty)
|
||||||
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.
|
|
||||||
|
|
||||||
Known limitations of the Wii Remote path:
|
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
|
- 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
|
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.
|
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]
|
> [!CAUTION]
|
||||||
> Only take builds from this repository's
|
> Only take builds from this repository's
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ if (_aurora_sdl3_provider STREQUAL "auto")
|
|||||||
set(_aurora_sdl3_provider "package")
|
set(_aurora_sdl3_provider "package")
|
||||||
else ()
|
else ()
|
||||||
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
|
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)
|
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF)
|
||||||
if (SDL3_FOUND)
|
if (SDL3_FOUND)
|
||||||
set(_aurora_sdl3_provider "system")
|
set(_aurora_sdl3_provider "system")
|
||||||
@@ -58,7 +61,7 @@ if (_aurora_sdl3_provider STREQUAL "system")
|
|||||||
message(STATUS "aurora: Using system SDL3 (provider=system)")
|
message(STATUS "aurora: Using system SDL3 (provider=system)")
|
||||||
if (NOT SDL3_FOUND)
|
if (NOT SDL3_FOUND)
|
||||||
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
|
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)
|
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF)
|
||||||
endif ()
|
endif ()
|
||||||
_aurora_sdl3_select_target()
|
_aurora_sdl3_select_target()
|
||||||
|
|||||||
@@ -1438,7 +1438,10 @@ void __PADWriteDeadZones(SDL_IOStream* file, // NOLINT(*-reserved-identifier)
|
|||||||
void PADSerializeMappings() {
|
void PADSerializeMappings() {
|
||||||
const std::filesystem::path basePath = fs_path_from_string(aurora::g_config.userPath);
|
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);
|
EnsureMappingLoaded(&controller);
|
||||||
const auto filePath =
|
const auto filePath =
|
||||||
basePath / fmt::format("{}_{:04X}_{:04X}.controller", aurora::input::controller_name(controller.m_index),
|
basePath / fmt::format("{}_{:04X}_{:04X}.controller", aurora::input::controller_name(controller.m_index),
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ std::string GetOSVersion() {
|
|||||||
constexpr auto name = "iOS";
|
constexpr auto name = "iOS";
|
||||||
#elif TARGET_OS_TV
|
#elif TARGET_OS_TV
|
||||||
constexpr auto name = "tvOS";
|
constexpr auto name = "tvOS";
|
||||||
#elif
|
#else
|
||||||
constexpr auto name = Unknown;
|
constexpr auto name = Unknown;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ else()
|
|||||||
message(FATAL_ERROR
|
message(FATAL_ERROR
|
||||||
"WiiCompiled supports 64-bit LLVM-MinGW Clang on Windows, native Linux x86_64/aarch64, or Apple Clang on macOS arm64")
|
"WiiCompiled supports 64-bit LLVM-MinGW Clang on Windows, native Linux x86_64/aarch64, or Apple Clang on macOS arm64")
|
||||||
endif()
|
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")
|
if(NOT CMAKE_BUILD_TYPE STREQUAL "Release")
|
||||||
message(FATAL_ERROR "WiiCompiled only supports Release builds")
|
message(FATAL_ERROR "WiiCompiled only supports Release builds")
|
||||||
endif()
|
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)
|
target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17)
|
||||||
add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests)
|
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
|
# HostContext deliberately keeps the platform-specific context primitive out
|
||||||
# of fiber_manager.cpp. Exercise the Linux libco handoff directly so future
|
# of fiber_manager.cpp. Exercise the Linux libco handoff directly so future
|
||||||
# refactors cannot silently remove its headers, implementation, or link edge.
|
# refactors cannot silently remove its headers, implementation, or link edge.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
#include <map>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -89,6 +90,8 @@ struct RuntimeUserConfig {
|
|||||||
// comma-separated SDL-style physical button names ("south", or
|
// comma-separated SDL-style physical button names ("south", or
|
||||||
// "dpad_up,left_shoulder") as values; pressing either bound button counts.
|
// "dpad_up,left_shoulder") as values; pressing either bound button counts.
|
||||||
std::array<std::optional<std::string>, 12> controllerButtons;
|
std::array<std::optional<std::string>, 12> controllerButtons;
|
||||||
|
std::optional<bool> rumbleEnabled;
|
||||||
|
std::map<std::string, std::string> controllerExpressions;
|
||||||
};
|
};
|
||||||
|
|
||||||
namespace RuntimeConfigFile {
|
namespace RuntimeConfigFile {
|
||||||
@@ -407,6 +410,17 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
|
|||||||
FindConfigValue<std::string>(document, "controller", buttonKeys[index]);
|
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.widescreen = FindConfigValue<bool>(document, "video", "widescreen");
|
||||||
config.windowPosX = FindConfigInt(document, "video", "window_x");
|
config.windowPosX = FindConfigInt(document, "video", "window_x");
|
||||||
config.windowPosY = FindConfigInt(document, "video", "window_y");
|
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));
|
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) {
|
inline bool SetAudioVolume(float value) {
|
||||||
value = std::clamp(value, 0.0f, 1.0f);
|
value = std::clamp(value, 0.0f, 1.0f);
|
||||||
Mutable().audioVolume = value;
|
Mutable().audioVolume = value;
|
||||||
|
|||||||
@@ -1,18 +1,75 @@
|
|||||||
#include "hle_stubs.h"
|
#include "hle_stubs.h"
|
||||||
#include "memory.h"
|
#include "memory.h"
|
||||||
#include "hle/controller_status_contract.h"
|
#include "hle/controller_status_contract.h"
|
||||||
|
#include "input_bindings.h"
|
||||||
#include "wii_remote_input.h"
|
#include "wii_remote_input.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
|
#include <SDL3/SDL_gamepad.h>
|
||||||
#include <dolphin/pad.h>
|
#include <dolphin/pad.h>
|
||||||
|
|
||||||
namespace {
|
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) {
|
void WritePadStatus(uint32_t base, const PADStatus& status) {
|
||||||
const auto guestStatus = PadStatusContract::Encode({
|
const auto guestStatus = PadStatusContract::Encode({
|
||||||
status.button,
|
status.button,
|
||||||
@@ -32,6 +89,11 @@ void WritePadStatus(uint32_t base, const PADStatus& status) {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
g_rumbleEnabled.store(enabled, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
extern "C" uint32_t PAD__Init_HLE()
|
extern "C" uint32_t PAD__Init_HLE()
|
||||||
{
|
{
|
||||||
return PADInit() ? 1u : 0u;
|
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.
|
// between "connected" and "no controller" every time the overlay toggles.
|
||||||
WiiRemoteInput::HideRemotesFromPad(statuses, PAD_CHANMAX);
|
WiiRemoteInput::HideRemotesFromPad(statuses, PAD_CHANMAX);
|
||||||
|
|
||||||
|
FillTriggersHeldByButtons(statuses);
|
||||||
|
InputBindings::Apply(statuses);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (uint32_t i = 0; i < PAD_CHANMAX; ++i) {
|
for (uint32_t i = 0; i < PAD_CHANMAX; ++i) {
|
||||||
WritePadStatus(statusPtr + static_cast<uint32_t>(i * PadStatusContract::kGuestStatusSize),
|
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)
|
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);
|
PADControlMotor(chan, command);
|
||||||
}
|
}
|
||||||
PPC_NATIVE_OVERRIDE_VOID(801AF908, PAD__ControlMotor_HLE, (int32_t chan, uint32_t command), (chan, command));
|
PPC_NATIVE_OVERRIDE_VOID(801AF908, PAD__ControlMotor_HLE, (int32_t chan, uint32_t command), (chan, command));
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
#include "settings_overlay.h"
|
#include "settings_overlay.h"
|
||||||
#include "audio_backend.h"
|
#include "audio_backend.h"
|
||||||
|
#include "controller_button_names.h"
|
||||||
#include "controller_mapping_wizard.h"
|
#include "controller_mapping_wizard.h"
|
||||||
|
#include "input_bindings.h"
|
||||||
#include "game_graphics_options.h"
|
#include "game_graphics_options.h"
|
||||||
#include "music_attenuation.h"
|
#include "music_attenuation.h"
|
||||||
#include "runtime_config.h"
|
#include "runtime_config.h"
|
||||||
@@ -34,6 +36,8 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#include <dolphin/pad.h>
|
#include <dolphin/pad.h>
|
||||||
|
|
||||||
|
extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled);
|
||||||
#include <dolphin/vi.h>
|
#include <dolphin/vi.h>
|
||||||
#include <aurora/aurora.h>
|
#include <aurora/aurora.h>
|
||||||
#include <aurora/gfx.h>
|
#include <aurora/gfx.h>
|
||||||
@@ -65,6 +69,7 @@ const char* GraphicsApiDisplayName() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool g_topBarVisible = false;
|
bool g_topBarVisible = false;
|
||||||
|
bool g_rumbleEnabled = RuntimeConfigFile::RumbleEnabled(true);
|
||||||
int g_controllerPort = 0;
|
int g_controllerPort = 0;
|
||||||
float g_resolutionScale = RuntimeConfigFile::ResolutionMultiplier(1.0f);
|
float g_resolutionScale = RuntimeConfigFile::ResolutionMultiplier(1.0f);
|
||||||
int g_audioVolumePercent = static_cast<int>(std::lround(RuntimeConfigFile::AudioVolume(1.0f) * 100.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;
|
return indices;
|
||||||
}();
|
}();
|
||||||
|
|
||||||
struct ControllerButtonItem {
|
using ControllerNames::kNativeButtons;
|
||||||
const char* configKey;
|
using ControllerNames::NativeButtonItem;
|
||||||
const char* label;
|
constexpr const auto& kControllerButtons = ControllerNames::kGameCubeButtons;
|
||||||
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},
|
|
||||||
}};
|
|
||||||
|
|
||||||
// Classic Controller Pro layout, indexed like kControllerButtons: the SNES-style
|
// 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
|
// 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",
|
"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 {
|
struct ResolutionItem {
|
||||||
const char* label;
|
const char* label;
|
||||||
float scale;
|
float scale;
|
||||||
@@ -225,43 +184,25 @@ void LimitResolutionForFrameRate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const NativeButtonItem* FindNativeButton(std::string value) {
|
using ControllerNames::FindNativeButton;
|
||||||
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), [&](const NativeButtonItem& item) {
|
|
||||||
return value == item.configName;
|
|
||||||
});
|
|
||||||
return it == kNativeButtons.end() ? nullptr : &*it;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ControllerBindingPair {
|
struct ControllerBindingPair {
|
||||||
std::string primary;
|
std::string primary;
|
||||||
std::string secondary;
|
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
|
// 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.
|
// "dpad_up,left_shoulder"); pressing either one counts as the GC button.
|
||||||
ControllerBindingPair SplitControllerBinding(const std::string& value) {
|
ControllerBindingPair SplitControllerBinding(const std::string& value) {
|
||||||
const size_t comma = value.find(',');
|
const size_t comma = value.find(',');
|
||||||
if (comma == std::string::npos) {
|
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) {
|
using ControllerNames::NativeButtonForValue;
|
||||||
const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), [&](const NativeButtonItem& item) {
|
|
||||||
return nativeButton == item.nativeButton;
|
|
||||||
});
|
|
||||||
return it == kNativeButtons.end() ? kNativeButtons.front() : *it;
|
|
||||||
}
|
|
||||||
|
|
||||||
void SetTopBarVisible(bool visible) {
|
void SetTopBarVisible(bool visible) {
|
||||||
if (g_topBarVisible == visible) {
|
if (g_topBarVisible == visible) {
|
||||||
@@ -393,10 +334,8 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) {
|
|||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (WiiRemoteInput::IsScanning() && WiiRemoteInput::PeriodicRescanEnabled()) {
|
if (WiiRemoteInput::IsScanning()) {
|
||||||
ImGui::TextDisabled("Scanning... (%u so far) - press 1+2 on the remote", WiiRemoteInput::ScanCount());
|
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 {
|
} else {
|
||||||
ImGui::TextDisabled("Not scanning");
|
ImGui::TextDisabled("Not scanning");
|
||||||
}
|
}
|
||||||
@@ -457,6 +396,105 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Controller settings menu: port selection, controller assignment and button mapping.
|
// 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() {
|
void DrawControllerSettings() {
|
||||||
for (int port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
|
for (int port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
|
||||||
const std::string label = "Port " + std::to_string(port + 1);
|
const std::string label = "Port " + std::to_string(port + 1);
|
||||||
@@ -545,20 +583,28 @@ void DrawControllerSettings() {
|
|||||||
PADSerializeMappings();
|
PADSerializeMappings();
|
||||||
mappings = PADGetButtonMappings(port, &mappingCount);
|
mappings = PADGetButtonMappings(port, &mappingCount);
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
const auto applyPreset = [&](const std::array<const char*, PAD_BUTTON_COUNT>& preset) {
|
||||||
if (ImGui::Button("Classic Controller Pro")) {
|
|
||||||
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
|
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
|
||||||
for (size_t i = 0; i < kControllerButtons.size(); ++i) {
|
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});
|
PADSetButtonMapping(port, PADButtonMapping{native->nativeButton, kControllerButtons[i].padButton});
|
||||||
PADSetAltButtonMapping(port,
|
PADSetAltButtonMapping(port,
|
||||||
PADButtonMapping{PAD_NATIVE_BUTTON_INVALID, kControllerButtons[i].padButton});
|
PADButtonMapping{PAD_NATIVE_BUTTON_INVALID, kControllerButtons[i].padButton});
|
||||||
RuntimeConfigFile::SetControllerButton(i, kClassicProPreset[i]);
|
RuntimeConfigFile::SetControllerButton(i, preset[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
altRowExpanded.fill(false);
|
altRowExpanded.fill(false);
|
||||||
PADSerializeMappings();
|
PADSerializeMappings();
|
||||||
mappings = PADGetButtonMappings(port, &mappingCount);
|
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");
|
ImGui::SeparatorText("Button mapping");
|
||||||
@@ -640,6 +686,8 @@ void DrawControllerSettings() {
|
|||||||
ImGui::TextUnformatted(kControllerButtons[i].label);
|
ImGui::TextUnformatted(kControllerButtons[i].label);
|
||||||
ImGui::PopID();
|
ImGui::PopID();
|
||||||
}
|
}
|
||||||
|
DrawExpressionSettings();
|
||||||
|
DrawRumbleSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void DrawAudioSettings() {
|
void DrawAudioSettings() {
|
||||||
@@ -994,6 +1042,8 @@ void PersistDisplayModeIfChanged() {
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void InitializeRuntimeSettings() noexcept {
|
void InitializeRuntimeSettings() noexcept {
|
||||||
|
PAD_HLE_SetRumbleEnabled(g_rumbleEnabled);
|
||||||
|
InputBindings::Reload();
|
||||||
controller_mapping_wizard::LoadPersistedMappings();
|
controller_mapping_wizard::LoadPersistedMappings();
|
||||||
ApplyConfiguredMappings();
|
ApplyConfiguredMappings();
|
||||||
AudioBackend::Instance().SetMasterVolume(static_cast<float>(g_audioVolumePercent) / 100.0f);
|
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_strapInputAccepted.store(false, std::memory_order_relaxed);
|
||||||
g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed);
|
g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed);
|
||||||
PADBlockInput(false);
|
PADBlockInput(false);
|
||||||
|
InputBindings::SetInputBlocked(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void HandleEvents(const AuroraEvent* events) noexcept {
|
void HandleEvents(const AuroraEvent* events) noexcept {
|
||||||
@@ -1056,7 +1107,9 @@ void Draw() noexcept {
|
|||||||
DrawTopBar();
|
DrawTopBar();
|
||||||
controller_mapping_wizard::Draw();
|
controller_mapping_wizard::Draw();
|
||||||
// The wizard captures raw presses; keep them out of the game.
|
// 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();
|
DrawStartupScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user