Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 67 additions & 4 deletions HARDWARE_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2131,9 +2131,11 @@ app touches first takes that path for the other two.
## 22. Guide button LED brightness

The intensity the driver sends at `startDevice()` is now the user's setting rather than a
hardcoded `0x14`, mapped from four presets in `PreferenceConfiguration.getGuideButtonLedValue()`:
hardcoded `0x14`, mapped from the presets in `PreferenceConfiguration.getGuideButtonLedValue()`:
off `0x00`, dim `0x0A`, normal `0x14`, bright `0x2F`. `normal` is what the driver sent before, so
an untouched install behaves exactly as it did.
an untouched install behaves exactly as it did. A fifth setting, "Battery level", starts at
`normal` and then moves the LED with each battery report — that is section 31's to verify; this
section covers the four fixed presets.

Two of those values are assertions about hardware that nothing off a device can settle.

Expand Down Expand Up @@ -2176,8 +2178,9 @@ next created — the next stream — not on re-plugging a pad. That is the exist
constructor path is wired up, not just the adapter's.
- [ ] **Both ABIs.** The change is in the shared driver, but the Shield is `arm64-v8a` and the
Homatics `armeabi-v7a`, and only a run on each proves the JNI signature change
(`createDriver` is now `(II)J`) binds. A mismatch fails at library load, so the symptom would
be the adapter not starting at all rather than the LED misbehaving.
(`createDriver` is now `(II)J`, and since section 31 `setLedNative` is `(JII)Z`) binds. A
mismatch fails at library load, so the symptom would be the adapter not starting at all
rather than the LED misbehaving.

---

Expand Down Expand Up @@ -2771,6 +2774,65 @@ it would say whether "never drop" was ever more than an accident of the PTS base

---

## 31. Guide LED following the battery

"Battery level" is a fifth value of the Xbox button brightness setting. The guide LED is
monochrome, so following the battery means brightness steps, taken from the presets so each one is
a level the user has already seen: full → bright `0x2F`, medium → normal `0x14`, low → dim `0x0A`,
and critical → a **slow blink** (`LED_BLINK_SLOW`, `0x04`) at normal intensity, because a warning
has to look like one and a dimmer steady LED across a room does not. A pad with no battery fitted
(type 0, running off its cable) goes bright. Before the first report, and for any level the spec
does not define, the pad shows the normal start value. The ladder is `GuideButtonLed.forBattery()`,
on the Java side so it is tested; the native side only applies what it is handed and refuses a
pattern outside `enum LedMode`.

The LED command is now built in one place, `Controller::applyLed()`: `startDevice()` applies
whatever is stored, and `Controller::setLed()` — the new `setLedNative` JNI entry — stores the
battery ladder's command and sends it if the device has started, or leaves it for `startDevice()`
if not, since a command sent in Arrival goes unanswered.

Also fixed on the way: on the cabled path the read thread runs before the Java object exists, and a
status that landed in that window used to arm the driver's change filter with nobody listening, so
Java heard nothing until the battery actually moved — hours, on a full pad. The filter now stays
unarmed until a report is delivered; MS-GIPUSB 2.2.9 has the pad repeat status every second for its
first ten seconds, so the next one is at most a second away.

Nothing here is on a per-frame path: one GIP frame per *level* change, on the driver's read thread,
deduplicated against charge-only changes. Like section 22, the setting is read once in
`UsbDriverService.onCreate()`, so the procedure is "change setting, leave the stream, start it
again".

- [ ] **The LED steps down over a discharge.** With "Battery level" set, run a pad down: bright at
full, visibly dimmer at medium, dimmer again at low. Each step is one log line:
```bash
adb shell setprop persist.log.tag '""'
adb logcat -d | grep -a "Guide button LED set to"
adb shell setprop persist.log.tag S
```
One line per level change, and none for a charge-state-only change (plug and unplug the
cable at a steady level): `GipController` deduplicates against the last command sent.
- [ ] **Critical actually blinks.** Table 42 marks every pattern except On as "not implemented by
host" — which describes Microsoft's host, not the pad — so whether a pad honours
`LED_BLINK_SLOW` is unverified, and xone's exposing the patterns through sysfs is inference
rather than proof. If the pad sits steady at `0x14` instead, or logs `Refused`/no ack, try
`BLINK_MED`, `BLINK_FAST` and the two `FADE`s in turn and record which of them reads as a
warning; only if none does, fall back to `PATTERN_ON` at `0x04` and note that critical then
reads like "dim". The spec also has hosts wait for two consecutive critical reports before
warning the user; the LED reacts to the first.
- [ ] **A battery-less cabled pad goes bright.** Type 0 in the status byte, whatever the level
field says (the spec has such a pad report critical in the level bits). Needs a pad with the
pack removed, on the wired GIP path (`wiredPadAudio` on).
- [ ] **A pad pairing mid-stream picks the ladder up.** Its first status after `startDevice()`
drives it; nothing about the setting is per-pad.
- [ ] **The cabled pad, wired GIP path.** Same ladder through `WiredController`, plus the
first-status fix above: the LED should reflect the pad within a couple of seconds of it being
claimed, not only after the battery next moves.
- [ ] **Both ABIs load.** `setLedNative` is a new `(JII)Z` entry in the controller's JNI table; a
mismatch fails at library load, so the symptom is the adapter or cabled pad not starting at
all rather than the LED misbehaving.

---

## Hardware still needed

| Needed for | Hardware |
Expand Down Expand Up @@ -2798,6 +2860,7 @@ it would say whether "never drop" was ever more than an accident of the PTS base
| §17.1 sub-50 fractional | A 23.976 or 29.97 Hz output mode specifically — 59.94 takes the other branch and cannot settle it |
| §18.2 two-client check | A second Moonlight client against the same host |
| §30 debug boxes | Shield: a debug build in min-latency for the presented counter, then cap-FPS for the gating check. Homatics: any build in min-latency. The mechanism itself is verified on the Shield's release build |
| §31 ladder | A rechargeable adapter pad run down to critical, a play-and-charge cable for the charge-only case, and a cabled pad with its battery pack removed for the type-0 case |
| §19 | Both target devices; the survey differs per SoC |
| §26 badge centring | Either box, on a television at normal viewing distance |
| §20 | The Homatics specifically — the Shield cannot verify a change scoped to `armeabi-v7a` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,24 @@ public class GipController extends AbstractController{
// Native controller instance owned by the driver, valid until the driver removes it
private final long handle;

/** @param handle native controller instance supplied by the dongle's driver */
public GipController(int deviceId, UsbDriverListener listener, int vendorId, int productId, long handle) {
// The user's guide LED choice, and the last command sent for it. Seeded with what
// startDevice() sends so a first battery report that maps to the same thing is not sent
// twice. Touched only on the driver's read thread - updateBattery() is the sole writer - so
// it needs no synchronisation.
private final GuideButtonLed guideButtonLed;
private GuideButtonLed.State ledSent;

/**
* @param handle native controller instance supplied by the dongle's driver
* @param guideButtonLed the guide LED choice; its starting intensity has already gone to the
* native side with the transport, this is for what follows
*/
public GipController(int deviceId, UsbDriverListener listener, int vendorId, int productId,
long handle, GuideButtonLed guideButtonLed) {
super(deviceId, listener, vendorId, productId);
this.handle = handle;
this.guideButtonLed = guideButtonLed;
this.ledSent = guideButtonLed.startState();

// An Xbox pad however it is attached, so it declares what every other Xbox pad does. It
// extends AbstractController rather than AbstractXboxController because the transport below
Expand Down Expand Up @@ -89,21 +103,26 @@ public void updateInput(int buttons,short triggerLeft, short triggerRight,
}

/**
* Called from the native driver when the controller's battery state changes, with the raw GIP
* values. The mapping lives here rather than in the driver because the constants it maps onto
* are Moonlight's.
* Called from the native driver, on its read thread, when the controller's battery state
* changes, with the raw GIP values. The mapping lives here rather than in the driver because
* the constants it maps onto are Moonlight's.
*
* <p>The status byte packs four fields (MS-GIPUSB Table 30). Type says what kind of battery is
* fitted, <em>not</em> whether it is charging — that is a separate field, which xow's struct
* discards into {@code connectionInfo} and which the driver now decodes. Type 0 means no
* battery at all: the pad is running off the cable. xow's enum calls that value
* {@code BATT_TYPE_CHARGING}, which is a misnomer the spec settles.
*
* <p>Also where the guide LED follows the battery, when the setting says so — see
* {@link #followBatteryLed}.
*
* @param type GIP battery type: 0 absent or bus powered, 1 standard, 2 rechargeable
* @param level GIP battery level, 0 to 3, meaningless when {@code type} is 0
* @param charge GIP charge state: 0 not charging, 1 charging, 2 charge error
*/
public void updateBattery(byte type, byte level, byte charge) {
followBatteryLed(type, level);

// No battery fitted: the pad is running off the cable, and its level means nothing
if (type == 0) {
reportBattery(MoonBridge.LI_BATTERY_STATE_NOT_CHARGING,
Expand Down Expand Up @@ -135,6 +154,33 @@ public void updateBattery(byte type, byte level, byte charge) {
reportBattery(state, percentage);
}

/**
* Steps the guide LED with the battery, when the user's choice is to follow it.
*
* <p>Deduplicated against the last command sent because the driver reports on any change in
* the status byte - charge state and power level included - and only the level and the
* battery type move the LED. A send the pad refused is not recorded, so the next report
* retries it.
*
* <p>Runs inside the battery callback, on the driver's read thread: the same thread
* {@code startDevice()} sends the first LED command from, so this adds no new cross-thread
* sender to the native side.
*/
private void followBatteryLed(byte type, byte level) {
if (!guideButtonLed.followsBattery()) {
return;
}

var next = GuideButtonLed.forBattery(type, level);
if (next.equals(ledSent)) {
return;
}

if (setLedNative(handle, next.pattern(), next.intensity())) {
ledSent = next;
}
}

/**
* @return true if this pad told us, in its metadata, that it can render 48 kHz stereo. A pad
* that did not has no audio endpoint reachable this way, and enabling would move the
Expand Down Expand Up @@ -239,5 +285,14 @@ public void forgetAudioDevice() {
native void queueAudioNative(long handle, short[] samples, int count);
native void sendRumble(long handle, short lowFreqMotor, short highFreqMotor);
native void sendrumbleTriggers(long handle, short leftTrigger, short rightTrigger);
/**
* Sends a guide LED command, or stores it for {@code startDevice()} if the pad is not started
* yet.
*
* @param pattern one of {@code GuideButtonLed.PATTERN_*}; anything else is refused
* @param brightness 0 to {@code GuideButtonLed.INTENSITY_MAX}; clamped natively
* @return whether the pad took it, or it was stored to be sent at start
*/
native boolean setLedNative(long handle, int pattern, int brightness);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.limelight.binding.input.driver;

/**
* The user's guide button LED choice for the Xbox pads this app drives, and the ladder that turns
* a battery level into an LED command when the choice is to follow the battery.
*
* <p>Every LED number on the Java side lives here. The values are the protocol's own, not
* percentages of anything we chose: MS-GIPUSB 3.1.5.5.7 Table 41 defines the LED command's
* intensity byte as 0 to 47 ({@code 0x2F}), and Table 42 its pattern byte. Keeping the whole
* translation on this side of the JNI boundary keeps it testable; the native side only applies
* what it is handed, and rejects a pattern it does not know.
*
* <p>Split out of {@link GipController}, which loads a native library in its static initialiser
* and so cannot be touched from a JVM test, following {@link StickCalibration}. Android-free.
*
* <p>Latency: none. Read once per pad at start and once per battery report, on the driver's own
* thread; never on the frame or input path.
*
* @param intensity what the pad is told at start, Table 41 units
* @param followsBattery whether battery reports move it after that
*/
public record GuideButtonLed(int intensity, boolean followsBattery) {

// Table 41 intensities. The presets, and the ceiling. xow believed the maximum was 0x20, so
// whether a pad honours anything above that is an open hardware question - see
// HARDWARE_TESTING.md 22.
public static final int INTENSITY_OFF = 0x00;
public static final int INTENSITY_DIM = 0x0A;
public static final int INTENSITY_NORMAL = 0x14;
public static final int INTENSITY_BRIGHT = 0x2F;
public static final int INTENSITY_MAX = 0x2F;

// Table 42 patterns. These mirror enum LedMode in app/src/main/jni/xow_driver/controller/gip.h
// and must stay equal to it: the native side accepts exactly this set and refuses anything
// else, which is what catches the two drifting apart.
public static final int PATTERN_OFF = 0x00;
public static final int PATTERN_ON = 0x01;
public static final int PATTERN_BLINK_FAST = 0x02;
public static final int PATTERN_BLINK_MED = 0x03;
public static final int PATTERN_BLINK_SLOW = 0x04;
public static final int PATTERN_FADE_SLOW = 0x08;
public static final int PATTERN_FADE_FAST = 0x09;

/** What the driver sent before the setting existed, and what anything unrecognised gets. */
public static final GuideButtonLed NORMAL = new GuideButtonLed(INTENSITY_NORMAL, false);

/**
* One LED command's worth: what the pad is told to show.
*
* @param pattern one of {@code PATTERN_*}
* @param intensity 0 to {@link #INTENSITY_MAX}
*/
public record State(int pattern, int intensity) {
}

/**
* @return what the driver sends when the pad starts. Pattern and intensity are separate
* fields (Table 42), so an intensity of zero on a lit pattern is not what the spec
* means by off: zero selects the off pattern rather than a maximally dimmed on one.
*/
public State startState() {
return new State(intensity == 0 ? PATTERN_OFF : PATTERN_ON, intensity);
}

/**
* The ladder: the LED for a battery report, when the setting is to follow it.
*
* <p>The steps are the presets, so each one is a brightness the user has already seen on the
* other settings: full is Bright, medium is Normal, low is Dim. Critical flashes rather than
* dimming further, because a dim LED across a room is indistinguishable from the Dim preset
* and a warning has to look like one; it flashes at Normal so the flash is visible. A pad with
* no battery fitted is running off its cable, and gets full brightness for the same reason a
* full battery does.
*
* <p>Only critical uses a pattern other than On. Table 42 marks every other pattern "not
* implemented by host", which describes Microsoft's host rather than the pad, so whether a
* pad honours the slow blink is a hardware question - HARDWARE_TESTING.md 31.
*
* @param type GIP battery type: 0 absent or bus powered, 1 standard, 2 rechargeable
* @param level GIP battery level, 0 critical to 3 full; meaningless when {@code type} is 0
* @return the command to send; a level the spec does not define gets what the pad was
* started with, so an unexpected value never changes anything
*/
public static State forBattery(byte type, byte level) {
if (type == 0) {
return new State(PATTERN_ON, INTENSITY_BRIGHT);
}
return switch (level) {
case 3 -> new State(PATTERN_ON, INTENSITY_BRIGHT);
case 2 -> new State(PATTERN_ON, INTENSITY_NORMAL);
case 1 -> new State(PATTERN_ON, INTENSITY_DIM);
case 0 -> new State(PATTERN_BLINK_SLOW, INTENSITY_NORMAL);
default -> NORMAL.startState();
};
}
}
Loading