Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
e66a935
start on the new input pipeline
isXander Oct 9, 2026
560c6d0
activator system & more
isXander Oct 10, 2026
b143851
flesh out the pipeline and start wiring things up
isXander Oct 11, 2026
006d8e4
forgot to git add
isXander Oct 12, 2026
d1286ce
allow gyro low pass cutoff hz to be configured
isXander Oct 12, 2026
b2df975
start to wire up the drivers to the new event system; start rewriting…
isXander Oct 12, 2026
dbebcc3
rewrite controller manager and start to wire up fixedupdate/update sy…
isXander Oct 14, 2026
a1ed3ce
extract some of Controlify into ControllerCoordinator; start to reimp…
isXander Oct 16, 2026
7683b39
wire up controller mapping to pipeline
isXander Oct 16, 2026
b7813fb
implement data-driven controller mappings; bit of reorganisation
isXander Oct 16, 2026
92928d6
repackage everything into api and impl packages; rewrite rumble api
isXander Oct 19, 2026
3fdbdb0
repackage the fabric and neoforge sourcesets
isXander Oct 19, 2026
ce182b5
it builds! it runs!
isXander Oct 22, 2026
a25c6da
remove deadzone debugging
isXander Oct 22, 2026
6e32a98
fix drivers never updating
isXander Oct 22, 2026
78853df
fix contextual domains loading from bad path; added warning to catch …
isXander Oct 22, 2026
8909d7a
make look input including gyro per-frame
isXander Oct 22, 2026
09a185b
add unit test for deadzone stage
isXander Oct 22, 2026
80dbabc
bump version
isXander Oct 22, 2026
ac82e56
fix game tests
isXander Oct 22, 2026
9a9b24a
spotless
isXander Oct 22, 2026
8650878
screenop 2
isXander Oct 22, 2026
3290899
more screenop 2 stuff
isXander Oct 23, 2026
5ace655
fix sodium screenop and misc screenop stuff
isXander Oct 23, 2026
1075911
fix test
isXander Oct 23, 2026
a6f3c39
fuck you spotless
isXander Oct 23, 2026
0e2174b
address a few small bugs
isXander Oct 23, 2026
03179de
stuff wasnt staged
isXander Oct 23, 2026
34586f7
stuff
isXander Oct 24, 2026
befcef8
make run configs rundir proper
isXander Oct 27, 2026
97a2236
action layer system; reorganise compat into separate package; misc ap…
isXander Oct 27, 2026
bf4f127
make tap and double tap gestures mutually exclusive, expose gesture t…
isXander Oct 27, 2026
89a647b
fix radial menu not invoking actions
isXander Oct 27, 2026
425d9e4
binding/gesture system; actions can be bound more than once!
isXander Oct 27, 2026
a6b8a1d
Merge branch 'main' into feat/input-pipeline-v3
isXander Oct 27, 2026
3c94d90
fix gamecontrollerdb being put in the wrong directory
isXander Oct 27, 2026
6d13b49
fix darkening on BindConsumerScreen missing a few pixels
isXander Oct 27, 2026
7063375
bump version (test 2)
isXander Oct 27, 2026
65521de
fix spotless
isXander Oct 27, 2026
68a048c
update stonecutter comments
isXander Oct 27, 2026
5f3b9d9
fix gametest
isXander Oct 27, 2026
e37899e
bump fabric loom
isXander Oct 28, 2026
aa1d900
fix warning using stale data in ContextualDomainImpl
isXander Oct 28, 2026
0ed1db5
add more docs and move some things around
isXander Oct 28, 2026
574dd2e
change a lot of activators to tap-by-default instead of press; more l…
isXander Oct 28, 2026
b5a4926
detect non-zero initial joystick axis states (such as triggers) and r…
isXander Oct 29, 2026
af0f755
reorganise a few more packages; remove TriggerEffectInstance from api
isXander Oct 29, 2026
8630806
remove controller not detected button
isXander Oct 29, 2026
78205bf
use unicode private use area for font mappings to avoid line splittin…
isXander Oct 29, 2026
8ffd2f8
fix ActionGlyphs creating invalid square characters when no controlle…
isXander Oct 29, 2026
fa197ef
proper valve hardware support!
isXander Nov 14, 2026
80c8601
remove double tap gesture
isXander Nov 14, 2026
5bab088
rename gestures
isXander Nov 14, 2026
c451a9d
cleanup
isXander Nov 14, 2026
d37f61c
gesture glyphs
isXander Nov 14, 2026
d241d94
fix some false conflicts with action layers
isXander Nov 14, 2026
ad09499
Merge branch 'refs/heads/feat/valve-hardware' into feat/input-pipelin…
isXander Nov 15, 2026
a1bd38f
feedback api (draft!)
isXander Nov 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ build
.gradle
!.gitkeep
.kotlin

.DS_Store
39 changes: 27 additions & 12 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,6 @@ dependencies {
commonApi(libs.sdl.java.backend.ffm)
commonInclude(libs.sdl.java.backend.ffm)

commonApi(libs.steamdeck4j)
commonInclude(libs.steamdeck4j)

commonApi(libs.quilt.parsers.json)

compileOnlyApi("dev.isxander:yet-another-config-lib:${property("dep.yacl")}") {
Expand Down Expand Up @@ -154,10 +151,10 @@ val commonManifest = manifests.manifest {

mixin("controlify.mixins.json")
mixin("controlify-compat.yacl.mixins.json")
ifPresent("deps.iris") { mixin("controlify-compat.iris.mixins.json") }
ifPresent("deps.sodium") { mixin("controlify-compat.sodium.mixins.json") }
ifPresent("deps.reeses-sodium-options") { mixin("controlify-compat.reeses-sodium-options.mixins.json") }
ifPresent("deps.svc") { mixin("controlify-compat.svc.mixins.json") }
ifPresent("dep.iris") { mixin("controlify-compat.iris.mixins.json") }
ifPresent("dep.sodium") { mixin("controlify-compat.sodium.mixins.json") }
ifPresent("dep.rso") { mixin("controlify-compat.reeses-sodium-options.mixins.json") }
ifPresent("dep.svc") { mixin("controlify-compat.simple-voice-chat.mixins.json") }
mixin("controlify-compat.rrls.mixins.json")

dependency("minecraft", REQUIRED, minecraftRange)
Expand All @@ -169,18 +166,24 @@ manifests {
"controlify:default_config",
"controlify:controller_type",
"controlify:default_binds",
"controlify:trigger_effect",
"controlify:contextual/domain/in_game",
"controlify:contextual/domain/container",
"controlify:contextual/ruleset/guide",
"controlify:contextual/ruleset/trigger_effect",
"controlify:activators",
"controlify:controller_mapping",
"controlify:rumble_sources",
"controlify:radial_icons",
)
))

fabricModJson(sourceSets.fabric.get()) {
from(commonManifest)

entrypoint("modmenu", "dev.isxander.controlify.fabric.compatibility.ModMenuIntegration")
entrypoint("main", "dev.isxander.controlify.fabric.ControlifyBootstrap")
entrypoint("client", "dev.isxander.controlify.fabric.ControlifyBootstrap")
entrypoint("server", "dev.isxander.controlify.fabric.ControlifyBootstrap")
entrypoint("modmenu", "dev.isxander.controlify.fabric.compat.ModMenuIntegration")
entrypoint("main", "dev.isxander.controlify.fabric.impl.ControlifyBootstrap")
entrypoint("client", "dev.isxander.controlify.fabric.impl.ControlifyBootstrap")
entrypoint("server", "dev.isxander.controlify.fabric.impl.ControlifyBootstrap")
dependency("fabricloader", REQUIRED, "[0.19,)")

mixin("controlify-platform.fabric.mixins.json")
Expand Down Expand Up @@ -281,6 +284,18 @@ if (includeNatives) {
}
}

// Run configs

// Fabric
loom.runConfigs.configureEach {
runDirectory = layout.projectDirectory.dir("run")
}

// NeoForge
runs.configureEach {
workingDirectory = layout.projectDirectory.dir("run")
}

/// Testing

fabricApi {
Expand Down
6 changes: 4 additions & 2 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ export default defineConfig({
{ text: 'Button Guides', link: '/resource-packs/guides' },
{ text: 'Keyboard Layouts', link: '/resource-packs/keyboard-layouts' },
{ text: 'Radial Menu Icons', link: '/resource-packs/radial-icons' },
{ text: 'Adaptive Trigger Effects', link: '/resource-packs/adaptive-trigger-effects' }
{ text: 'Adaptive Trigger Effects', link: '/resource-packs/adaptive-trigger-effects' },
{ text: 'Feedback Effects', link: '/resource-packs/feedback-effects' }
]
},
{
Expand All @@ -97,7 +98,8 @@ export default defineConfig({
{ text: 'Controlify Entrypoint', link: '/developers/controlify-entrypoint' },
{ text: 'Bindings API', link: '/developers/bindings-api' },
{ text: 'Screen Operation API', link: '/developers/screen-operation-api' },
{ text: 'Contextual API', link: '/developers/contextual-api' }
{ text: 'Contextual API', link: '/developers/contextual-api' },
{ text: 'Feedback API', link: '/developers/feedback-api' }
]
},
{
Expand Down
132 changes: 132 additions & 0 deletions docs/content/developers/feedback-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
title: Feedback API
---

# Feedback API

Feedback is everything the player feels through the controller. The API splits it in two:

- A `FeedbackEvent` says **when** something should be felt. Your code fires it.
- A `FeedbackEffect` says **what** it feels like. This is usually a
[data file](../resource-packs/feedback-effects.md), so resource packs can change it.

The API is in `dev.isxander.controlify.api.feedback` and is marked experimental.

::: tip
If the situation you want feedback for is already described by contextual state, you may not need any code. A
[rule with no event](../resource-packs/feedback-effects.md#rules-with-no-event) plays an effect whenever a predicate is
true.
:::

## Declaring an event

Declare an event once and keep it in a static field. The params are a type of your own; each `param` call also exposes
one of its numbers by name for data-driven effects.

```java
public record Recoil(float force) {}

public static final FeedbackEvent<Recoil> RECOIL = FeedbackEvent.builder(Identifier.fromNamespaceAndPath("mymod", "recoil"))
.param("force", Recoil::force)
.build();

// an event with no params
public static final FeedbackEvent<Void> RELOAD = FeedbackEvent.builder(Identifier.fromNamespaceAndPath("mymod", "reload"))
.build();
```

Register events in your [Controlify entrypoint](controlify-entrypoint.md) so they appear in diagnostics:

```java
FeedbackEvents.REGISTER.subscribeForever(registry -> {
registry.registerEvent(RECOIL);
registry.registerEvent(RELOAD);
});
```

## Firing and holding

Emit sites hold no state. Events play on the current controller.

```java
// one-shot
RECOIL.fire(new Recoil(0.8f));

// one-shot at a position: it gets weaker with distance on its own
RECOIL.fireAt(pos, new Recoil(0.8f));

// continuous: call every tick while the condition is true
if (charging) {
CHARGE.hold(new Charge(progress));
}

// optional: end a hold at once, instead of when the calls stop
CHARGE.release();
```

A held event ends by itself about three ticks after you stop calling `hold`, so `release` is only for when it must
stop immediately. Each `hold` call refreshes the params.

With nothing else set up, an event plays the effect with its own id, so `mymod:recoil` plays
`assets/mymod/controller/feedback/recoil.json`. If no such effect exists, firing it does nothing.

## Effects in code

Write the effect as a [data file](../resource-packs/feedback-effects.md) where you can. Use code for an effect that
needs logic a data file cannot express.

A code effect receives your params with their real type:

```java
FeedbackEvents.REGISTER.subscribeForever(registry -> {
registry.registerEffect(RECOIL, (playback, out) -> {
float force = playback.params().force();
float remaining = 1f - playback.seconds() / 0.2f;
if (remaining <= 0f) {
return false; // finished
}
out.rumble(force * remaining, force * 0.5f);
return true;
});
});
```

- An effect is stateless. Everything about the playback is in `Playback`: `seconds()`, `params()`, `held()`,
`released()` and `sinceRelease()`.
- Effects are sampled once per rendered frame, so work in seconds, not ticks.
- Return `false` when the effect is over. An effect for a held event should return `false` once `released()` is true,
or after a tail of its own.
- A data effect with the same id always wins over a code effect, so resource packs can still replace yours.

`registerEffect(event, effect)` registers the event's default effect. To register a variant that
[selection rules](../resource-packs/feedback-effects.md#rules-for-an-event) can choose, give it its own id:
`registerEffect(id, event, effect)`. Mark the event with `.selection()` when you build it so its rules are consulted.

### The output frame

`FeedbackFrame` describes what should be felt without naming any hardware.

| Method | Meaning |
|--------|---------|
| `band(site, band, amp)` | A rumble band (`LOW` or `HIGH`). Every controller can play this. |
| `rumble(low, high)` | Both bands on the whole controller |
| `voice(site, hz, amp)` | A tone at a frequency, for HD haptics |
| `click(site, intensity, sharpness)` | A single transient |
| `hd()` | Whether the controller reproduces voices and clicks faithfully |

Call `click` once, on the frame it should happen. `playback.crossed(seconds)` is true for exactly one frame:

```java
if (playback.crossed(0.1f)) {
out.click(Site.BODY, 0.8f, 0.7f);
}
```

How each controller plays a frame:

| Controller | Bands | Voices | Clicks |
|------------|-------|--------|--------|
| Ordinary rumble | The two motors | Folded into the nearest band | A short burst |
| Trigger motors | Trigger sites only | Amplitude only | A short burst |
| DualSense (HD haptics on) | Synthesised tones | Synthesised at their frequency | A synthesised burst |
| Steam Controller (2026) | The rumble motors | A tone command | A tick or click command |
Loading