Files
wiicompiled/runtime/tests/test_expr.cpp
T
Nicholas Bly be153e0fa0 Add Dolphin-compatible input expressions and GCPadNew.ini import, DualSense L/R remapping, vibration toggle (#89)
* Add Dolphin-compatible input expressions and GCPadNew.ini import

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

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

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

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

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

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

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

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

* Update runtime/src/input_expr.cpp

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

* Update runtime/src/input_expr.cpp

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

* Update runtime/src/input_expr.cpp

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

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-09-05 10:07:05 +02:00

220 lines
8.2 KiB
C++

// 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;
}