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
99 changes: 95 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,93 @@ it would say whether "never drop" was ever more than an accident of the PTS base

---

## 31. Controller battery: the in-game label, and the guide LED following it

Two additions built on the battery reports the app already collected and only ever forwarded to
the host. Neither touches a per-frame path: battery writes happen at battery-change frequency on
driver threads, the label is built once per menu level while the decoder is already stalled behind
the dialog, and the LED sends one GIP frame per *level* change on the driver's read thread.

**The label.** While any level of the in-game menu is up, a small end-aligned line along the
bottom of the screen lists every controller's battery, one entry per host player number:
`Controller 1: 75% · Controller 2: 50%, charging · Controller 3: wired`. "wired" is a GIP pad
running off its cable with no battery pack — it reports `NOT_CHARGING` and no percentage, and the
cable is the only fact in that. The store behind it is `ControllerBatteries`; the menu keeps it
shown across submenus with a `DialogChain` token and hides it when its last dialog closes.

The GIP percentages are the spec's four levels mapped as `GipController.updateBattery()` always
has: full 100, medium 50, low 25, critical 10. Shield and Bluetooth pads report real percentages.

Also fixed on the way: a GIP pad's first status packet usually arrives before its first input
report, which is what assigns its player number, so that first battery report used to reach the
host as player 0. It is now held on the context until the number is assigned. And on the cabled
path, a status that landed before the Java object existed used to arm the driver's change filter
with nobody listening, so Java heard nothing until the battery actually moved; the filter now
stays unarmed until a report is delivered, and MS-GIPUSB 2.2.9 has the pad repeat status every
second for its first ten seconds.

**The LED.** "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`.

Like section 22, the setting is read once in `UsbDriverService.onCreate()`, so the procedure is
"change setting, leave the stream, start it again".

The open hardware questions:

- [ ] **The label is legible under the dim.** Open the menu with two pads in different states.
The dialog dims everything behind it at the theme's default 60%; the text is the primary
colour rather than the secondary one the settings version label uses for exactly this
reason. If it is still too faint from the sofa, that is a colour change, not a dim change.
- [ ] **Several controllers, in player order.** Both pads listed, numbered as the host numbers
them. The pad-audio submenu now uses the same numbers (it used to count pads by their
position in the driver's list, which disagreed whenever the Shield controller or a
Bluetooth pad held a slot): with the Shield controller as player 1, a GIP pad should read
"Controller 2" in both places. A paired pad nobody has touched reads "Unused controller" in
the audio menu until it sends input, and is absent from the label. With multi-controller
off the audio menu keeps positional numbers, since every pad is player 0 there.
- [ ] **The label survives the menu's own navigation.** Open Controllers, then Back: the label
stays. Cancel, or Back at the root: the label goes. Choose Disconnect: the label goes.
- [ ] **A pad that unplugs mid-menu is gone on the next open.** Its number is released, which
clears its slot.
- [ ] **A battery-less cabled pad reads "wired".** Requires a pad with the pack removed, on the
wired GIP path (`wiredPadAudio` on).
- [ ] **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).
- [ ] **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 label and the LED should both 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 +2888,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 "wired" and type-0 cases |
| §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
58 changes: 57 additions & 1 deletion app/src/main/java/com/limelight/Game.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.limelight.binding.PlatformBinding;
import com.limelight.binding.audio.LowLatencyAudioRenderer;
import com.limelight.binding.audio.PadAudioSink;
import com.limelight.binding.input.ControllerBatteries;
import com.limelight.binding.input.ControllerHandler;
import com.limelight.binding.input.GameInputDevice;
import com.limelight.binding.input.KeyboardTranslator;
Expand Down Expand Up @@ -89,6 +90,7 @@
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
Expand Down Expand Up @@ -166,6 +168,10 @@ private record ScheduledStep(long uptimeMs, TextKeyPlanner.Step step) {}
// comment.
private TextView imePreviewOverlay;

// The controllers' battery levels, along the bottom edge while the in-game menu is up. Never
// focused either; the menu's dialog owns focus for as long as this is visible.
private TextView controllerBatteryOverlay;

// Bottom margin currently applied to the echo, so the insets listener only re-lays it out when
// the keyboard actually changes size rather than on every insets change during a stream.
private int imePreviewInset = -1;
Expand Down Expand Up @@ -446,6 +452,7 @@ protected void onCreate(Bundle savedInstanceState) {
performanceSparklines = findViewById(R.id.performanceSparklines);

imePreviewOverlay = findViewById(R.id.imePreviewOverlay);
controllerBatteryOverlay = findViewById(R.id.controllerBatteryOverlay);

inputCaptureProvider = InputCaptureManager.getInputCaptureProvider(this);

Expand Down Expand Up @@ -2470,7 +2477,8 @@ public void togglePerformanceOverlay() {
}

/**
* Opens the in-stream menu.
* Opens the in-stream menu. The menu also shows every controller's battery along the bottom
* of the screen for as long as it is up - see {@link #showControllerBatteryLabel}.
*
* @param device the controller that requested it, so the menu can offer that controller's own
* options, or null when opened from a keyboard or gesture
Expand All @@ -2485,6 +2493,49 @@ public void showGameMenu(GameInputDevice device) {
new GameMenu(this, conn, device);
}

/**
* Fills the battery label from the controllers' last readings and shows it, or hides it when
* no controller has anything to show. One entry per controller, in player order, so several
* pads read side by side rather than the last one reported winning.
*
* <p>Numbered as the host numbers them - player 1 is controller number 0 - which is also how
* the pad-audio submenu names pads, so a number here is the same pad there.
*
* <p>UI thread only: the menu opens from {@link #onBackPressed} or a controller button on the
* main thread and calls this before showing each level. The label is a child of this
* activity's window, not a new layer, and the decoder is already stalled behind the menu's
* dialog, so the cost of building the text is not on any frame.
*/
public void showControllerBatteryLabel() {
var parts = new ArrayList<String>();
for (ControllerBatteries.Reading reading : controllerHandler.batterySnapshot()) {
int player = reading.controllerNumber() + 1;
switch (reading.display()) {
case LEVEL -> parts.add(getString(R.string.controller_battery_level,
player, reading.percentage()));
case CHARGING -> parts.add(getString(R.string.controller_battery_charging,
player, reading.percentage()));
case WIRED -> parts.add(getString(R.string.controller_battery_wired, player));
// Never in a snapshot; listed so the switch stays exhaustive when Display grows
case HIDDEN -> { }
}
}

if (parts.isEmpty()) {
controllerBatteryOverlay.setVisibility(View.GONE);
return;
}

controllerBatteryOverlay.setText(
String.join(getString(R.string.controller_battery_separator), parts));
controllerBatteryOverlay.setVisibility(View.VISIBLE);
}

/** Takes the battery label down. UI thread; the menu calls it when its last dialog closes. */
public void hideControllerBatteryLabel() {
controllerBatteryOverlay.setVisibility(View.GONE);
}

/**
* @return true if the USB driver has an Xbox wireless adapter running, which is what decides
* whether the game menu offers to pair a controller with it. False when the USB
Expand Down Expand Up @@ -2520,6 +2571,11 @@ public PadAudioSink getPadAudioSink() {
return padAudioSink;
}

/** @return the multi-controller setting, which decides how the game menu numbers pads */
public boolean isMultiControllerEnabled() {
return prefConfig.multiController;
}

/**
* Turns audio to a pad's headphone jack on or off mid-stream, reporting the outcome. A refusal
* is either the two-pad bandwidth limit or a pad with no audio endpoint at all, and the toast
Expand Down
51 changes: 45 additions & 6 deletions app/src/main/java/com/limelight/GameMenu.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,19 @@
import com.limelight.binding.input.driver.GipController;
import com.limelight.nvstream.NvConnection;
import com.limelight.nvstream.input.KeyboardPacket;
import com.limelight.utils.DialogChain;
import com.limelight.utils.MenuDialog;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

/**
* Provide options for ongoing Game Stream.
* <p>
* Shown on back action in game activity.
* Shown on back action in game activity. While any level of it is up, {@link Game} also shows the
* controllers' battery levels along the bottom of the screen; the menu is what decides when that
* label appears and goes away.
*/
public class GameMenu {

Expand All @@ -41,6 +45,9 @@ public MenuOption(String label, Runnable runnable) {
private final NvConnection conn;
private final GameInputDevice device;

// One menu is one chain of dialogs; see showMenuDialog for what it settles.
private final DialogChain chain = new DialogChain();

/** Building the menu shows it immediately; there is no separate show call. */
public GameMenu(Game game, NvConnection conn, GameInputDevice device) {
this.game = game;
Expand Down Expand Up @@ -129,6 +136,11 @@ private void showMenuDialog(String title, MenuOption[] options) {
* own dialog with no relationship to the one that opened it, so without this a submenu's Back
* dropped straight to the stream - further out than the user asked to go. The root passes
* null, where dismissing really does mean returning to the game.
*
* <p>The battery label is shown afresh at every level, so a reading that changed while a
* submenu was up is picked up on the way back, and hidden only when the whole chain ends.
* Every dialog's {@code onDismiss} fires, including a parent's after its child has opened, so
* each one takes a {@link DialogChain} token and only the latest holder hides the label.
*/
private void showMenuDialog(String title, MenuOption[] options, Runnable onBack) {
List<MenuDialog.Option> rows = new ArrayList<>();
Expand All @@ -142,7 +154,14 @@ private void showMenuDialog(String title, MenuOption[] options, Runnable onBack)
: runnable));
}

MenuDialog.show(game, title, 0, rows, onBack, null);
int token = chain.opened();
game.showControllerBatteryLabel();

MenuDialog.show(game, title, 0, rows, onBack, () -> {
if (chain.closes(token)) {
game.hideControllerBatteryLabel();
}
});
}

/**
Expand All @@ -152,14 +171,33 @@ private void showMenuDialog(String title, MenuOption[] options, Runnable onBack)
* cannot be enabled because the two-pad limit is reached says so on the row. The cap is a
* bandwidth budget on a link shared with controller input, so it is worth showing rather
* than letting a selection quietly do nothing.
*
* <p>Pads are named by the host's player number - see {@link GameMenuLayout#padNumber} for
* why, and for the two cases that cannot be - and listed in that order, with any pad that
* has no number yet at the end.
*/
private void showPadAudioMenu() {
List<GipController> controllers = game.getGipControllers();
PadAudioSink sink = game.getPadAudioSink();
List<MenuOption> options = new ArrayList<>();
boolean multiController = game.isMultiControllerEnabled();

int number = 1;
for (GipController controller : controllers) {
// Number before sorting: the position fallback is the driver's order, which is this one
var numbers = new int[controllers.size()];
for (int i = 0; i < controllers.size(); i++) {
numbers[i] = GameMenuLayout.padNumber(multiController,
controllers.get(i).getPlayerNumber(), i + 1);
}
var order = new ArrayList<Integer>();
for (int i = 0; i < controllers.size(); i++) {
order.add(i);
}
order.sort(Comparator.comparingInt(i ->
numbers[i] == GameMenuLayout.PAD_UNNUMBERED ? Integer.MAX_VALUE : numbers[i]));

for (int i : order) {
GipController controller = controllers.get(i);
int number = numbers[i];
boolean enabled = sink.isEnabled(controller);

/*
Expand Down Expand Up @@ -197,8 +235,9 @@ else if (sink.canEnableMore()) {
state = getString(R.string.game_menu_pad_audio_unavailable);
}

String label = game.getResources().getString(
R.string.game_menu_pad_audio_entry, number++, state);
String label = number == GameMenuLayout.PAD_UNNUMBERED
? game.getResources().getString(R.string.game_menu_pad_audio_entry_unnumbered, state)
: game.getResources().getString(R.string.game_menu_pad_audio_entry, number, state);

options.add(new MenuOption(label, () -> game.togglePadAudio(controller)));
}
Expand Down
Loading