# GameFlow Wiki Deep reference for every system in GameFlow. For install/quick-start, see [README.md](README.md). ## Contents 1. [Core Concepts](#core-concepts) 2. [Rule Reference](#rule-reference) 3. [Formula Language](#formula-language) 4. [Shift Layers](#shift-layers) 5. [Gyro Aiming](#gyro-aiming) 6. [Touchpad Mapping](#touchpad-mapping) 7. [Per-Device Tuning](#per-device-tuning) 8. [Device Calibration](#device-calibration) 9. [Cross-Platform Input & Output](#cross-platform-input--output) 10. [Phone as a Controller](#phone-as-a-controller) 11. [OBS Browser Overlay](#obs-browser-overlay) 12. [Theme System](#theme-system) 13. [Troubleshooting](#troubleshooting) 14. [For Contributors](#for-contributors) --- ## Core Concepts **Profile** — a JSON document holding a slot's polling rate, input provider, and its ordered list of mapping rules. **Slot** — one independent input → profile → output pipeline. You can run several at once (local co-op, or one device split across several transformed outputs), each with its own device assignment, output kind, and output provider. **Tick** — one pass through a slot's `ControllerMappingPipeline`. At the configured polling rate (30–1000 Hz), the pipeline: reads the physical snapshot → applies [per-device tuning](#per-device-tuning) → merges multiple devices if the slot has more than one assigned → resolves the active [shift layer](#shift-layers) → runs every rule type's pass in a fixed order → writes the result to the output sink. **Rule ordering / last-write-wins** — within one rule-type's pass, rules run in the order they appear in the profile. If two rules of the same type target the same output, the later one wins. This is the same convention every rule type in GameFlow uses, so once you understand it for one, you understand it for all. **Base vs. layers** — a rule with no `LayerId` (empty string) is a **Base** rule and is always active. A rule tagged with a layer id is only active while that [shift layer](#shift-layers) is engaged. Layer rules run *after* Base rules in the same pass, so a layer's remap for a control naturally overrides Base's remap for the same control — you don't need to do anything special, just order your rules. --- ## Rule Reference All 14 rule types, in the order their passes run each tick. | # | Rule | What it does | |---|---|---| | 1 | `RuleToggleRule` | On button press, flips another rule's `Enabled` flag on/off. Runs first since everything downstream reads `Enabled`. | | 2 | `SocdCleanRule` | Resolves one opposite-direction button pair. Runs early — it's input hygiene, not a creative remap, so everything else should see the cleaned result. | | 3 | `ButtonRemapRule` | Source button → target button, with optional source suppression. | | 4 | `ButtonAutofireRule` | Pulses one button at a configurable rate while held. | | 5 | `MultiButtonAutofireRule` | Same, but any-of-several source buttons arms it. | | 6 | `ButtonComboRule` | One press → a timed sequence of virtual button presses (with per-step delay/hold). | | 7 | `StickAutofireRule` | Pulses a stick direction, with jitter-resistant hysteresis at the threshold. | | 8 | `FreezeLastDirectionRule` | Captures the stick vector on the rising edge of an activation button; optionally keeps pulsing it while frozen. | | 9 | `StickTrimRule` | A held digital button arms it; a stick then modulates an *analog trigger* press. | | 10 | `GyroMapRule` | See [Gyro Aiming](#gyro-aiming). | | 11 | `TouchpadMapRule` | See [Touchpad Mapping](#touchpad-mapping). | | 12 | `MultiSourceMapRule` | Many sources → one target, via a combine mode or formula. See below. | | 13 | `ControlScriptRule` | Sandboxed Lua (MoonSharp) per control, for anything the built-in types don't cover. | | 14 | `StickThresholdRule` | Deadzone / full-at shaping, applied directly to the *virtual* output (distinct from the *physical*-side shaping in [Per-Device Tuning](#per-device-tuning)). | ### Multi-Source Rows — combine modes A `MultiSourceMapRule` has a list of **sources** (button, trigger, stick axis X/Y, stick magnitude, or gyro pitch/yaw/roll — each optionally inverted) and one **target** (a button via a press threshold, a stick axis, or a trigger). The sources fold into one value via a **combine mode**: | Mode | Behavior | |---|---| | `Maximum` | Largest value wins — "any of these" for buttons, strongest push for analog. | | `Minimum` | Smallest value wins — "all of these" for buttons. | | `Sum` | Values add (clamped on write). | | `Average` | Arithmetic mean. | | `Multiply` | Values multiply — a natural gate: a released button (0) zeroes the product regardless of the other sources. | | `FirstActive` | The first source in list order past a small activity threshold wins outright — priority ordering. | | `Formula` | See [Formula Language](#formula-language). | A row **owns its target for the tick**: if the combined value is below the press threshold, the target releases even if the physical button is still held. `SuppressSources` optionally zeroes each source's own contribution to the virtual output, so only the combined target carries the input. > These six modes plus Formula are this implementation's own selection for the domain — worth knowing if you're comparing against another tool's exact mode names. --- ## Formula Language A small, dependency-free expression compiler — not a scripting language, deliberately: for arithmetic over a handful of sources, a purpose-built parser is a smaller, more predictable trust boundary than routing through a full interpreter. **Sources:** `s1`, `s2`, ... `sN` (1-indexed, matching the row's source list). Referencing `s3` on a two-source row is a **compile error**, not a silent zero — a typo should be caught, not become a dead input. **Operators:** `+ - * /` (unary `-` too), parentheses, comparisons `< > <= >= == !=` (return `1`/`0`), logic `and or not` (also `&& || !`). **Functions:** `if(cond, a, b)`, `min(a, b, ...)`, `max(a, b, ...)`, `abs(x)`, `clamp(x, lo, hi)`. **Safety:** division by zero yields `0`, not infinity or an exception. ### Starter recipes | Name | Formula | Use | |---|---|---| | Two buttons → one axis | `s1 - s2` | A D-pad pair or two keys become an analog axis | | Strongest input wins | `max(s1, s2)` | Whichever source is pushed hardest drives the output | | Both required (gate) | `if(s1 > 0.5, s2, 0)` | s2 passes through only while s1 is held | | Blend 50/50 | `(s1 + s2) / 2` | Two people steering one wheel | | Weighted blend | `s1 * 0.7 + s2 * 0.3` | A main input with a trim input | | Boost while held | `if(s2 > 0.5, s1, s1 * 0.5)` | Walk/sprint | | Invert | `-s1` | Flip a source's direction | | Threshold to digital | `s1 > 0.4` | A trigger becomes a button at 40% pull | | Sum, capped | `clamp(s1 + s2, 0, 1)` | Two sources stack, never past full press | | Deadzone re-map | `clamp((abs(s1) - 0.15) / 0.85, 0, 1)` | Ignore the first 15% of travel, rescale the rest to 0..1 | --- ## Shift Layers "Caps Lock for your controller" — extra rule tables that turn on while a button, chord, or axis fires. At most **one** non-Base layer is active at a time; engaging a new one always replaces whatever was active, including a different latched or cycled layer. | Mode | Behavior | |---|---| | `Hold` | Active only while the activator is physically held. Released → Base immediately. | | `Toggle` | Press to turn on, press again to turn off. Supports **hold-to-fire** (a quick tap does its normal job; a hold flips the layer) and **auto-cancel** (idle timeout drops back to Base so you're never stranded). | | `Latch` | Press to turn on and stay on. Turns off by pressing the *same* activator again, or *switches directly* to a different Latch layer without detouring through Base. | | `Cycle` | Steps forward through an ordered queue of other layers; a second button steps back. Wrap-around and whether Base is one of the stops are both configurable. A Cycle entry doesn't gate any rule itself — it only orchestrates the queue. | | `Sticky` | Press once to engage; stays active through exactly the *next* button press elsewhere, then reverts automatically — classic "sticky keys" behavior. | | `NoButton` | Has no activator of its own; can only become active by being a stop in a Cycle queue. | A **stick gate** (used by Gyro's engage modes, and available generally) reads the *raw* physical stick — before any deadzone shaping — so a nudge too small for the game to act on can still arm something. --- ## Gyro Aiming Turns a pad's angular velocity into aim. Raw input is radians/second, following SDL's own convention (positive = counter-clockwise, axes X-right/Y-up/Z-toward-you). ### Reference frames | Frame | Behavior | |---|---| | `Local` | Raw axes, no correction. Yaw turns you horizontally, pitch aims vertically. Exact and predictable — but "horizontal" tilts with the pad if you hold it leaned. | | `Player` | Combines yaw and roll using the accelerometer as a gravity reference, so twisting the pad about the world's vertical always reads as horizontal aim, even leaned. Ignores pitch's contribution to horizontal. | | `World` | Full projection onto the world's vertical axis — horizontal aim is correct at *any* orientation, including holding the pad sideways. | Both `Player` and `World` fall back to `Local` when there's no accelerometer reading, rather than producing garbage from a zero gravity vector. ### Engage modes `AlwaysOn`, `HoldToEngage`, `HoldToDisable` (inverse — gyro is live by default, holding silences it), `Toggle`. Any mode can additionally be armed by the stick gate. ### Smoothing Dual-threshold: movement below the lower threshold is fully smoothed (kills hand tremor and sensor noise while holding still); movement above the upper threshold passes through completely raw (keeps fast flicks sharp); it blends linearly between the two. ### Calibration Per-axis bias values subtract out steady drift (a gyro at rest still reports a small non-zero rate). A deadzone on the *combined* magnitude catches residual creep after bias correction. Bias capture currently has no in-app UI — values are set by hand in the profile JSON. ### Output Drives a stick (clamped at a documented reference angular rate) or the mouse (via the same delta channel the touchpad's mouse mode uses — angular velocity is a *rate*, so this scales by elapsed time between ticks, unlike the touchpad's already-per-frame deltas). --- ## Touchpad Mapping For any pad with a touch surface (DualSense, DS4, and others SDL exposes touchpad data for). - **Stick anchor** — the moment a finger touches down, that position becomes the anchor (like a phone game's virtual joystick appearing wherever you tap). Movement away from the anchor drives stick deflection. - **Wedge D-pad** — anchor-relative direction bucketed into 4 or 8 wedges; 8-way diagonals hold two adjacent buttons at once, the standard way to represent 8 directions on 4 buttons. - **Mouse mode** — genuinely different from the stick/D-pad modes: it's **frame-to-frame**, not anchor-relative, matching how a real laptop touchpad works (it doesn't matter where your finger first landed). Touch Y and screen Y both already grow downward, so — unlike the stick mode — mouse mode does **not** negate Y. All three modes can be enabled simultaneously on one rule. --- ## Per-Device Tuning Click any virtual controller panel on the Dashboard to open a slot's device editor. The editor does not require connected hardware: an offline assignment retains its **slot AND device** key, and a slot with no assignment exposes inheritable slot defaults. A device-specific entry overrides those defaults, so the same physical pad can still be tuned two different ways on two different slots without them fighting each other. **Sticks:** deadzone, anti-deadzone (lifts the floor past a game's own internal deadzone), full-at (lets a worn stick that no longer reaches its corners still hit 100%), sensitivity, response curve (Linear / Precision-squared / Aggressive-sqrt), per-axis invert. Shaping is **radial**, not per-axis: deadzone and saturation apply to the stick's magnitude with direction preserved. A per-axis approach produces a square dead region, so a diagonal push escapes the deadzone at a different physical distance than a straight one — the classic "diagonals feel wrong" bug. This is applied before any mapping rule sees the input, and before multi-device merging (each device in a multi-device slot is conditioned with its *own* settings, then merged). **Triggers:** deadzone, full-at, sensitivity, invert. **Rumble, Lighting, Adaptive Triggers:** settings persist immediately and are delivered on the effects thread to an assigned, supported physical controller while it is connected. **React to rumble** (Adaptive tab) links a trigger to the game's live rumble. *Resistance* scales the configured effect's strength with the rumble level — free travel when the game is quiet, the strength you set at full rumble; the effect you picked still decides the shape of the resistance, the link only moves how hard it pushes back. *Vibration* replaces the effect with the trigger's own actuator buzzing at the rumble level and your configured frequency, and hands back to the configured effect between events so a trigger tuned to resist is not slack in the quiet. **Amount** (0–100%) scales the link, and turning it to zero leaves the static effect running rather than silencing the trigger. Both links read the *overall* rumble level — the louder of the two motors — rather than pairing the left trigger to the low motor and the right to the high. Splitting them reads better on paper than it works: a game that drives only one motor would leave one trigger permanently dead, which from the outside is indistinguishable from the feature being broken. The level is taken after the Rumble tab's gain, so a pad whose rumble you turned off has quiet triggers too. Firmware runs one effect per trigger, so *Vibration* overrides the configured mode rather than blending with it. The linked level is quantized to 16 steps: the actuator cannot resolve finer, and every distinct value is another report on a Bluetooth link already writing at 60 Hz. --- ## Device Calibration Tuning shapes values that already arrive in the right place. Calibration is for when they don't — when the pad's physical layout doesn't match what SDL believes the device is. The usual cause is an adapter borrowing someone else's identity. A PS2-to-USB or PS2-to-PS3 converter presents a DualShock 3's VID/PID (`054C:0268`), so SDL applies the DualShock 3 mapping, and any place the converter's wiring differs comes out scrambled — most often the right stick landing on the axes that mapping calls the triggers, so the stick reads dead while moving it pulls L2/R2. Both passes live on the **Devices** tab under BUTTON MAPPING, and both can be driven from inside the [Setup guide](README.md#how-to-use). They save per device into `device-button-maps.json`, merge into one map rather than overwriting each other, and **Clear mapping** drops the whole entry. ### Where it applies In `SdlUnifiedInputSource`, on the snapshot, before it leaves the input source — so ahead of per-device tuning, rule passes, and the output sink. The corrected value is what the virtual controller emits *and* what the on-screen layout draws, because they read the same snapshot; the picture cannot agree with the artwork and disagree with the game. Both passes read through the **raw joystick handle**, never the gamepad API — the entire premise is that SDL's view of which physical control is which is wrong for this device. That also means axis and button indices match the LIVE STATE readout on the same page. ### Buttons and hats Press-to-detect, fifteen prompts. Hats are watched as well as buttons: a D-pad is a hat on nearly every gamepad, so a pass that only watched buttons would sit on "Press: D-pad Up" forever, which reads as the D-pad not working rather than as the wizard not listening. ### Sticks and triggers Move-to-detect, six prompts — two per stick, one per trigger. Each stick is two prompts because a stick is two independent axes on the wire, and a converter that scrambles the order rarely moves X and Y together. Every prompt asks for the canonical **positive** direction — right, up, or fully pulled — so the sign of the travel *is* the orientation and there is no separate "is it inverted?" question. A binding records where the value comes from and how its travel maps onto the target: | | | |---|---| | **Source** | A raw axis, a raw button, or `None` (silenced) | | **Range** | `Full` (rests centred, −1..+1 — a stick axis) · `Half` (rests at 0, travels one way) · `Unipolar` (rests at one extreme, spans its whole travel as 0..1) | | **Invert** | Negates the raw reading *before* the range is applied, which is what lets one `Half` cover an axis travelling either way, and one `Unipolar` cover a trigger resting at either end | Range is inferred from the resting position, not guessed from the live value — an axis parked at an extreme while nothing touches it is a unipolar trigger; one parked near centre is a stick half-axis. Only a resting sample can tell those apart. **Digital triggers.** If no axis moves during a trigger prompt, pressing a button binds it as an on/off trigger — full value pressed, zero released. Pads whose L2/R2 are plain switches (most PS2 converters) have no trigger axis to bind, and a game reading the analog axis would otherwise get nothing. An analog trigger also reports a digital button, and that button closes early in the pull, so a button press is held back ~400 ms to see whether an axis follows; the axis wins if it does. Without that, a pressure-sensitive L2 would quietly be reduced to a switch. **Silence.** Rebinding the right stick onto the trigger axes does not stop SDL reporting that same motion as L2/R2 — the stick keeps pulling a trigger nobody touched. `None` forces a target to zero. This is why an explicitly silenced target is distinct from an unbound one: unbound keeps whatever SDL produced. **Waiting for rest.** A prompt does not start listening until every axis is back within tolerance of its resting position, sampled once when the pass opens. Letting go of a stick is a full-scale movement — larger, usually, than the deliberate push that answered the previous prompt — so a prompt that armed the instant it appeared would be answered by the release of the control before it, in the wrong direction, leaving no room to reach for anything. The hint line says so while it waits, because a prompt that silently ignores input reads as a broken controller. If the controls sit completely still somewhere *other* than rest for four seconds, that position is accepted as the new rest. This is for hardware whose idle values genuinely move — a PS2 converter toggled between analog and digital mode does exactly that — and the window is long enough that a hand does not trigger it by accident. ### What it does not do It does not hide the physical pad from games; see [Known Limitations](README.md#known-limitations). It also cannot help a control the device never reports at all — the LIVE STATE readout on the same page is the check for that: if nothing moves there, nothing reaches GameFlow. --- ## Cross-Platform Input & Output | Capability | Windows | Linux | macOS | |---|---|---|---| | Gamepad/joystick input | SDL3 | SDL3 | SDL3 | | Keyboard/mouse as source | Raw Input | `evdev` direct reads | `IOHIDManager` | | Mouse cursor output | `SendInput` | `uinput` | `CGEventPost` | | Virtual gamepad output | HIDMaestro | — | — | ### Verification notes The Linux `evdev`/`uinput` interop (struct layouts, ioctl numbers) was verified by compiling small C programs against the actual kernel headers and cross-checking the output — not derived from memory. The macOS `IOHIDManager`/`CGEventPost` interop is written against Apple's documented, stable API surface, but **could not be verified against real headers or hardware** during development (no macOS toolchain was available) — treat it as a good-faith implementation that hasn't had a hardware pass yet. **macOS specifically:** reading used to go through `CGEventTap`, which has no per-device concept — one aggregate stream for every keyboard and mouse system-wide, so per-device selection in the UI could only fall back to that aggregate. It now goes through `IOHIDManager`, which reports which device each value came from, so macOS matches evdev's one-file-per-device and Raw Input's per-handle model. Output stays on `CGEventPost`: synthesis has no per-device dimension to lose. Three consequences of that move are worth knowing: * **Input Monitoring consent is checked explicitly** (`IOHIDCheckAccess` / `IOHIDRequestAccess`) and logged whichever way it goes. It has to be: without consent the manager still creates, still opens and still enumerates devices, and only the value callback goes quiet — a refusal is otherwise indistinguishable from a reader that does not work. macOS prompts once, so a refusal can only be undone in System Settings. * **Hot-plug works for reading.** The manager keeps matching devices after it opens, so a keyboard plugged in later reports without a restart — Linux, which opens its fds once at construction, still needs one. * **Two small gaps came with it.** Absolute-mode pointers are ignored rather than misread as deltas (their X is a position, not a movement), and the volume/mute keys are gone — they are Consumer-page usages, on a HID device the reader deliberately does not claim. **Linux permissions:** `/dev/input/eventN` needs the `input` group (see [README](README.md#linux)). Without it, GameFlow runs fine — that input source just reads as empty, logged once. **macOS permissions:** keyboard/mouse capture needs Input Monitoring (System Settings → Privacy & Security). Same graceful-empty behavior without it. --- ## Phone as a Controller A tiny embedded HTTP + WebSocket server (`.NET`'s built-in `HttpListener` — no ASP.NET Core dependency for one page and one socket). The page is a single self-contained HTML document with **zero external requests**, since a phone on a LAN may have no real internet route at all. **Wire protocol:** a fixed bit-order button mask (documented in `WebControllerProtocol.cs`, deliberately *not* derived from any enum's declaration order, so a refactor elsewhere can't silently break it), plus stick/trigger axes, plus optional motion fields. **Motion:** the phone's gyroscope/accelerometer, converted from the browser's degrees/second to SDL's radians/second before sending, so a phone arrives in the exact same units a DualSense does and drives `GyroMapRule` with no phone-specific code downstream. iOS requires an explicit permission tap (`DeviceMotionEvent.requestPermission()`); Android doesn't gate it. **Connection identity:** up to 16 phones can connect at once. Each browser tab stores an opaque id in `sessionStorage`, so an automatic reconnect reclaims the same `web-pad-N` identity and keeps its slot assignment. Tabs use separate ids and can act as separate controllers. Every socket also receives a generation-style lease: when a newer connection replaces it, delayed input, rumble reads, and disconnect cleanup from the old socket are ignored. This prevents two sockets from fighting over one pad after a Wi-Fi interruption. **Disconnect safety:** the page sends a one-second heartbeat. A pad with no traffic for five seconds reads as fully neutral rather than its last input, so a phone that dies mid-press cannot leave a button stuck down. Input messages are limited to 4096 bytes and may arrive as WebSocket fragments; malformed or out-of-range values are rejected or clamped before reaching the mapping pipeline. **Rumble:** device-neutral effect writes targeting `web-pad-*` are diverted before SDL handle lookup and queued to the owning WebSocket. Sending feedback runs independently of receiving input, so a stationary phone does not delay rumble. Because the portable Vibration API is binary rather than variable-amplitude, the page represents motor strength as a short duty cycle repeated until GameFlow sends an explicit zero state. Disconnecting cancels vibration immediately. Browsers without the API continue working as input devices with no rumble. --- ## OBS Browser Overlay GameFlow serves a transparent controller layout from the same HTTP listener as the phone controller. Open **Settings → Stream overlay**, choose a slot and skin, then copy the generated URL into an OBS **Browser** source. No screen capture, QR scan, or always-on-top native window is involved. The route is `/overlay` with optional query parameters: | Parameter | Meaning | |---|---| | `slot=` | Pin the overlay to one controller slot. Omit it to use the first configured slot. | | `theme=` | Pin a specific installed skin. Omit it to follow the slot's output controller style. | | `side=physical` | Show the raw assigned input. Omit it to show the mapped virtual output the game receives. | For example, `http://10.0.0.5:8080/overlay?slot=player-one&side=physical` follows the physical side of `player-one`. The URL is the complete configuration and remains reusable across OBS launches. If the PC's LAN address changes, copy the regenerated address from Settings. The Browser source receives a compiled theme once, then controller frames at 60 Hz over an output-only WebSocket. Theme images are served by numeric indexes from the already-resolved theme program, so the route cannot browse arbitrary filesystem paths. The page automatically reconnects if GameFlow restarts and keeps a transparent canvas at any OBS source size. --- ## Theme System Controller visuals use the [VSCView THEMEENGINE](https://github.com/Nielk1/VSCView/blob/master/THEMEENGINE.md) format: a JSON tree of image/slider/showhide nodes, each with an input expression evaluated against a symbol table (`stick_left:x`, `key:f7`, `triggers:l:analog`, etc.) built from the current controller snapshot. **Keyboard themes** bake keys and legends directly into the body image (matching how the bundled default theme works) — a theme with only geometry and no rendered artwork will show nothing. `keyboard-100-default` is a full ANSI-104 layout with all keys individually addressable via `key:` symbols. **Stand-ins.** A controller style with no theme pack installed draws with the closest family member instead of drawing nothing: PlayStation 3 borrows the DualShock 4 layout, and the legacy generic Xbox style (which older persisted preferences still carry) borrows Xbox One. Both ship no pack of their own, so both previously rendered an empty panel — a DualShock 3, and anything presenting itself as one, had no artwork at all. Stand-ins are restricted to controllers whose button complement is a superset of the original, so nothing the pad can do goes undrawn; a DualShock 4 has every control a DualShock 3 has, plus a touchpad that simply never lights up. Substitution happens once, at style resolution, and **only** when the alternative is drawing nothing — a style that has its own themes never reaches a stand-in, so uninstalling every DualShock 4 skin still surfaces the "install a theme" message rather than quietly showing a DualSense. The skin picker keeps matching exactly, which is what keeps a skin named "Black" unambiguous. Each substitution is logged once per scan, since it is otherwise invisible. Dropping a real `dualshock-3` theme folder into the themes directory needs no code change: the folder name classifies itself, and its presence stops the substitution. **Known issue:** several bundled gamepad themes have imperfect button/stick placement — they were generated from an asset pack's individual sprite crops without authoritative layout coordinates. The correct fix is template-matching each sprite against its full-canvas base image to derive true pixel positions; this is scoped but not yet done. --- ## Troubleshooting **A slot's dashboard panel shows animated input I never gave it.** No device is assigned to that slot — it's falling back to the Demo preview source, which intentionally animates. Assign a real device in the Devices tab. **Web controller says "connection lost" from a phone.** On Windows, binding to all network interfaces needs an admin URL ACL; without it the server falls back to localhost-only (check the log — it states which mode it's in): ``` netsh http add urlacl url=http://+:8080/ user=Everyone ``` **The phone controls work but it does not vibrate.** Browser support is optional, and some mobile browsers require a user gesture before allowing vibration. Touch a controller control once, keep the page in the foreground, and confirm that the browser exposes the Vibration API. Input continues normally when vibration is unavailable. **The OBS Browser source is blank.** Keep GameFlow running, confirm the URL still uses this PC's current LAN address, and open **Settings → Stream overlay** to verify the selected slot and skin still exist. A bare `/overlay` needs at least one configured slot and an installed skin it can resolve. **Clicking a virtual panel does nothing.** That slot has no device assigned — there's nothing to tune. The status bar says so; assign a device first. **The wrong button lights up, or a stick is dead and moving it pulls a trigger.** The pad's physical layout doesn't match the identity SDL recognised — standard on PS2-to-USB and PS2-to-PS3 converters, which borrow a DualShock 3's VID/PID. Run both passes under [Device Calibration](#device-calibration). Check the LIVE STATE readout on the Devices page first: if the control moves nothing there, the device isn't reporting it at all and no remap can reach it. **The game responds to my input twice.** Both the physical and the virtual pad are visible to it. GameFlow does not hide devices from games and has no [HidHide](https://github.com/nefarius/HidHide) integration — that is a separate tool. If you use one, whitelist `GameFlow.App.exe` in it, or GameFlow loses the physical pad along with every other application. **HIDMaestro output isn't appearing.** Confirm `HIDMaestro.Core.dll` is next to `GameFlow.App.exe`. If it's genuinely missing, GameFlow says so explicitly in the log and the slot's display name — it will not silently fall back to a different backend without telling you. --- ## For Contributors - **Adding a rule type:** follow the existing pattern in `src/GameFlow.Core/Models/Rules/` — a record deriving `MappingRule`, registered in `MappingRule`'s `[JsonDerivedType]` list, with its pass added to `ControllerMappingPipeline.Process()`. Keep state that needs to persist across ticks (schedulers, latches) as a small dictionary field on the pipeline, matching every other stateful rule. - **Platform interop:** if you're touching `EvdevInterop.cs`/`UinputInterop.cs`, verify struct layouts and ioctl numbers by compiling a small C program against real headers rather than trusting memory — that's how the existing Linux interop was grounded, and it caught a real ABI mismatch during development (`ioctl`'s request parameter needing 8 bytes, not 4, on x86_64). - **The runtime tick is an allocation-free path.** `RuntimeCoordinator`'s loop runs at the profile's polling rate — up to 1000 Hz — and everything it reaches runs that often too, once per enabled slot. Anything allocated there becomes garbage-collection pressure, and a collection pause is what a player feels as input lag, so treat a per-frame `new`, LINQ chain, string interpolation or reflection call as a defect rather than a style question. The established patterns: partition rules into typed arrays once when the pipeline is built (`ControllerMappingPipeline`'s constructor), keep reusable scratch buffers as fields, cache anything derived from configuration rather than recomputing it, and publish a shared snapshot on mutation instead of cloning on read (`SlotRegistry`). Where a value must be pushed to another component, compare it against what was last pushed and skip the call when nothing changed — see `RuntimeCoordinator.PublishOwnedHardwareSignatures`. `ControllerSnapshot.Buttons` is a `ButtonMask` — one bit per button in a `uint` — for exactly this reason; reach for the same shape before adding another per-frame collection. - **Adding a field to the virtual controller:** the HIDMaestro sink builds one `HMGamepadState` per frame, and every field it does not write still goes out on the wire as zero — which the consuming game believes. That is not the same as the field being absent, and it has bitten this project three times: virtual pads reporting ~10% battery forever, a touchpad click that was silently dropped, and a virtual DualSense reporting itself perfectly still with nothing on its touch surface. When the SDK grows a state member, check whether GameFlow has the data for it, and cover it in `HidMaestroSubmitCoverageTests`. Where GameFlow genuinely cannot know a value, say so explicitly rather than writing a zero — `HasGyro` is the model: a pad with no sensor submits *no motion*, not "not moving". - **Tests:** `tests/GameFlow.Core.Tests` covers the pipeline and pure logic (no OS dependency); `tests/GameFlow.Infrastructure.Tests` covers platform interop, controller effects, device ownership, web protocols, and overlay compilation. Run `dotnet test GameFlow.sln` before handing off a change. - **Versioning:** the single source of truth is `Directory.Build.props`'s ``, used as the fallback for local builds. Tagged releases (`v*`) override it via `-p:Version=${GITHUB_REF_NAME#v}` in `.github/workflows/ci.yml` — tag `v1.0.1` and CI picks it up with no workflow changes needed.