| 1 | package com.rox.input; | |
| 2 | ||
| 3 | import de.gurkenlabs.input4j.ComponentType; | |
| 4 | import de.gurkenlabs.input4j.InputComponent; | |
| 5 | import de.gurkenlabs.input4j.InputDevice; | |
| 6 | import de.gurkenlabs.input4j.InputDevicePlugin; | |
| 7 | import de.gurkenlabs.input4j.InputDevices; | |
| 8 | ||
| 9 | import java.awt.event.KeyEvent; | |
| 10 | import java.io.IOException; | |
| 11 | import java.io.InputStream; | |
| 12 | import java.lang.reflect.Field; | |
| 13 | import java.nio.file.Files; | |
| 14 | import java.nio.file.Path; | |
| 15 | import java.util.Collection; | |
| 16 | import java.util.HashMap; | |
| 17 | import java.util.List; | |
| 18 | import java.util.Locale; | |
| 19 | import java.util.Map; | |
| 20 | import java.util.Optional; | |
| 21 | import java.util.Properties; | |
| 22 | import java.util.function.Supplier; | |
| 23 | import java.util.logging.Logger; | |
| 24 | import java.util.stream.Collectors; | |
| 25 | ||
| 26 | /** | |
| 27 | * Loads a {@link ControllerConfiguration} from a {@code .properties} file - see | |
| 28 | * {@code controllers.properties.example} at the repo root for the format. {@link #load} is the thin | |
| 29 | * I/O entry point; {@link #parse(Properties)} is the pure, directly-testable core that does the actual | |
| 30 | * interpretation. | |
| 31 | */ | |
| 32 | public final class ControllerConfigLoader { | |
| 33 | private static final String FOUR_SCORE_ENABLED_KEY = "fourscore.enabled"; | |
| 34 | private static final int PLAYER_COUNT = 4; | |
| 35 | private static final float DEFAULT_AXIS_THRESHOLD = 0.5f; | |
| 36 | private static final Logger log = Logger.getLogger(ControllerConfigLoader.class.getName()); | |
| 37 | ||
| 38 | //input4j's own InputComponent.ID.get(String) only finds IDs whose owning component class has | |
| 39 | //already been loaded by the JVM (each class registers its constants as a static-initializer side | |
| 40 | //effect) - relying on that here would make a config's resolvability depend on incidental | |
| 41 | //class-loading order (e.g. which real devices happened to be enumerated first), not the config | |
| 42 | //itself. Reading de.gurkenlabs.input4j.components.Button/Axis's own declared fields directly (the | |
| 43 | //same reflection approach as resolveKeyCode below, against KeyEvent.VK_*) sidesteps that entirely - | |
| 44 | //friendly per-controller-type names (e.g. XInput's "A") are deliberately not included here since | |
| 45 | //ID's own equals()/registration logic means only the canonical generic name ever survives anyway. | |
| 46 | private static final Map<String, InputComponent.ID> KNOWN_COMPONENTS = loadKnownComponents(); | |
| 47 | ||
| 48 | private ControllerConfigLoader(){ | |
| 49 | } | |
| 50 | ||
| 51 | private static Map<String, InputComponent.ID> loadKnownComponents(){ | |
| 52 | final Map<String, InputComponent.ID> components = new HashMap<>(); | |
| 53 | for (final Class<?> componentClass : List.of( | |
| 54 | de.gurkenlabs.input4j.components.Button.class, de.gurkenlabs.input4j.components.Axis.class)){ | |
| 55 | for (final Field field : componentClass.getFields()){ | |
| 56 | if (InputComponent.ID.class.isAssignableFrom(field.getType())){ | |
| 57 | try { | |
| 58 | final InputComponent.ID id = (InputComponent.ID) field.get(null); | |
| 59 | components.put(id.name, id); | |
| 60 | } catch (IllegalAccessException e){ | |
| 61 | throw new IllegalStateException("Could not read input4j component field " + field, e); | |
| 62 | } | |
| 63 | } | |
| 64 | } | |
| 65 | } | |
| 66 | return components; | |
| 67 | } | |
| 68 | ||
| 69 | public static ControllerConfiguration load(final Path path) throws IOException { | |
| 70 | final Properties properties = new Properties(); | |
| 71 | try (InputStream in = Files.newInputStream(path)){ | |
| 72 |
1
1. load : removed call to java/util/Properties::load → KILLED |
properties.load(in); |
| 73 | } | |
| 74 |
1
1. load : replaced return value with null for com/rox/input/ControllerConfigLoader::load → KILLED |
return parse(properties); |
| 75 | } | |
| 76 | ||
| 77 | public static ControllerConfiguration parse(final Properties properties){ | |
| 78 |
1
1. parse : replaced return value with null for com/rox/input/ControllerConfigLoader::parse → KILLED |
return parse(properties, ControllerConfigLoader::availableDevices); |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Test seam: a fake {@code deviceSupplier} makes {@code source=gamepad} parsing fully testable | |
| 83 | * without a real physical gamepad, mirroring how {@code SpeakerAudioOutput} takes a real-line | |
| 84 | * seam - matches {@link #availableDevices()}'s own doc for why that's the one genuinely | |
| 85 | * hardware-dependent piece of this class. | |
| 86 | */ | |
| 87 | static ControllerConfiguration parse(final Properties properties, final Supplier<Collection<InputDevice>> deviceSupplier){ | |
| 88 | final boolean fourScoreEnabled = Boolean.parseBoolean(properties.getProperty(FOUR_SCORE_ENABLED_KEY, "false")); | |
| 89 | final LazyDevices devices = new LazyDevices(deviceSupplier); | |
| 90 | final Controller[] players = new Controller[PLAYER_COUNT]; | |
| 91 |
2
1. parse : negated conditional → KILLED 2. parse : changed conditional boundary → KILLED |
for (int i = 0; i < PLAYER_COUNT; i++){ |
| 92 |
1
1. parse : Replaced integer addition with subtraction → KILLED |
players[i] = parsePlayer(properties, i + 1, devices); |
| 93 | } | |
| 94 |
1
1. parse : replaced return value with null for com/rox/input/ControllerConfigLoader::parse → KILLED |
return new ControllerConfiguration(players[0], players[1], players[2], players[3], fourScoreEnabled); |
| 95 | } | |
| 96 | ||
| 97 | //the plugin returned by the one real InputDevices.init() call this class ever makes (from | |
| 98 | //availableDevices() below) - input4j documents that result as owning real hardware resources | |
| 99 | //(open device handles), not something whose device list can just be read and discarded. There is | |
| 100 | //no config-reload path in this codebase, so tracking only the single most-recently-opened plugin | |
| 101 | //(rather than e.g. a per-ControllerConfiguration handle) is a deliberate, sufficient simplification. | |
| 102 | private static volatile InputDevicePlugin activePlugin; | |
| 103 | ||
| 104 | /** | |
| 105 | * Enumerates real, currently-connected gamepads via input4j. | |
| 106 | * <p> | |
| 107 | * XXX Mutation coverage expected to have issues here since it deals with real hardware (and CI has | |
| 108 | * no gamepad attached) - just accepting it for now, same as {@code SpeakerAudioOutput.openDefaultLine()}. | |
| 109 | */ | |
| 110 | private static Collection<InputDevice> availableDevices(){ | |
| 111 | final InputDevicePlugin plugin = InputDevices.init(); | |
| 112 | activePlugin = plugin; | |
| 113 |
2
1. availableDevices : replaced return value with Collections.emptyList for com/rox/input/ControllerConfigLoader::availableDevices → NO_COVERAGE 2. availableDevices : negated conditional → NO_COVERAGE |
return plugin == null ? List.of() : plugin.getAll(); |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * Closes the input4j plugin opened by the most recent real gamepad enumeration (a no-op if | |
| 118 | * gamepads were never used, or none are currently open) - callers should call this once emulation | |
| 119 | * has stopped polling gamepads, mirroring how {@code RomVideoSmokeDemo} already closes its | |
| 120 | * {@code SwingVideoOutput} in its own outer {@code finally}. | |
| 121 | * <p> | |
| 122 | * XXX Mutation coverage expected to have issues here since it deals with real hardware (and CI has | |
| 123 | * no gamepad attached) - just accepting it for now, same as {@link #availableDevices()}. | |
| 124 | */ | |
| 125 | public static void closeConnectedGamepads() throws IOException { | |
| 126 | final InputDevicePlugin plugin = activePlugin; | |
| 127 |
1
1. closeConnectedGamepads : negated conditional → KILLED |
if (plugin != null){ |
| 128 | activePlugin = null; | |
| 129 |
1
1. closeConnectedGamepads : removed call to de/gurkenlabs/input4j/InputDevicePlugin::close → KILLED |
plugin.close(); |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | /** Test seam: lets a test drive {@link #closeConnectedGamepads()}'s "an open plugin gets closed" path with a fake plugin, without ever touching real hardware. */ | |
| 134 | static void setActivePluginForTesting(final InputDevicePlugin plugin){ | |
| 135 | activePlugin = plugin; | |
| 136 | } | |
| 137 | ||
| 138 | /** Only calls {@code deviceSupplier} if some player actually needs a gamepad - not for keyboard/none-only configs. */ | |
| 139 | private static final class LazyDevices { | |
| 140 | private final Supplier<Collection<InputDevice>> source; | |
| 141 | private Collection<InputDevice> cached; | |
| 142 | ||
| 143 | LazyDevices(final Supplier<Collection<InputDevice>> source){ | |
| 144 | this.source = source; | |
| 145 | } | |
| 146 | ||
| 147 | Collection<InputDevice> get(){ | |
| 148 |
1
1. get : negated conditional → KILLED |
if (cached == null){ |
| 149 | cached = source.get(); | |
| 150 | } | |
| 151 |
1
1. get : replaced return value with Collections.emptyList for com/rox/input/ControllerConfigLoader$LazyDevices::get → KILLED |
return cached; |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | private static Controller parsePlayer(final Properties properties, final int playerNumber, final LazyDevices devices){ | |
| 156 | final String prefix = "player" + playerNumber; | |
| 157 | final String source = properties.getProperty(prefix + ".source", "none"); | |
| 158 |
1
1. parsePlayer : negated conditional → KILLED |
if ("keyboard".equalsIgnoreCase(source)){ |
| 159 |
1
1. parsePlayer : replaced return value with null for com/rox/input/ControllerConfigLoader::parsePlayer → KILLED |
return new KeyboardController(parseKeyBindings(properties, prefix)); |
| 160 | } | |
| 161 |
1
1. parsePlayer : negated conditional → KILLED |
if ("gamepad".equalsIgnoreCase(source)){ |
| 162 | //parsed even when the gamepad isn't connected, so a typo isn't hidden until it's plugged in | |
| 163 | final Map<Button, GamepadBinding> bindings = parseGamepadBindings(properties, prefix); | |
| 164 |
1
1. parsePlayer : replaced return value with null for com/rox/input/ControllerConfigLoader::parsePlayer → KILLED |
return findDevice(properties, prefix, devices.get()) |
| 165 |
1
1. lambda$parsePlayer$0 : replaced return value with null for com/rox/input/ControllerConfigLoader::lambda$parsePlayer$0 → KILLED |
.<Controller>map(device -> new GamepadController(device, bindings)) |
| 166 | .orElse(Controller.NONE); | |
| 167 | } | |
| 168 |
1
1. parsePlayer : negated conditional → KILLED |
if ("none".equalsIgnoreCase(source)){ |
| 169 |
1
1. parsePlayer : replaced return value with null for com/rox/input/ControllerConfigLoader::parsePlayer → KILLED |
return Controller.NONE; |
| 170 | } | |
| 171 | throw new IllegalArgumentException("Unknown controller source '" + source + "' for " + prefix); | |
| 172 | } | |
| 173 | ||
| 174 | /** | |
| 175 | * The configured gamepad, or empty (with a warning) if it isn't connected - that player then starts | |
| 176 | * with no controller rather than the emulator failing to start. | |
| 177 | * | |
| 178 | * @throws IllegalArgumentException for a negative {@code deviceIndex} - a config mistake, not a missing pad | |
| 179 | */ | |
| 180 | private static Optional<InputDevice> findDevice(final Properties properties, final String prefix, final Collection<InputDevice> devices){ | |
| 181 | final List<InputDevice> deviceList = List.copyOf(devices); | |
| 182 | final String nameSubstring = properties.getProperty(prefix + ".gamepad.device"); | |
| 183 |
1
1. findDevice : negated conditional → KILLED |
if (nameSubstring != null){ |
| 184 | final String needle = nameSubstring.toLowerCase(Locale.ROOT); | |
| 185 | final Optional<InputDevice> match = deviceList.stream() | |
| 186 |
2
1. lambda$findDevice$0 : replaced boolean return with false for com/rox/input/ControllerConfigLoader::lambda$findDevice$0 → KILLED 2. lambda$findDevice$0 : replaced boolean return with true for com/rox/input/ControllerConfigLoader::lambda$findDevice$0 → KILLED |
.filter(d -> d.getDisplayName().toLowerCase(Locale.ROOT).contains(needle)) |
| 187 | .findFirst(); | |
| 188 |
1
1. findDevice : negated conditional → KILLED |
if (match.isEmpty()){ |
| 189 |
1
1. findDevice : removed call to com/rox/input/ControllerConfigLoader::warnMissing → KILLED |
warnMissing("No connected gamepad matches '" + nameSubstring + "'", prefix, deviceList); |
| 190 | } | |
| 191 |
1
1. findDevice : replaced return value with Optional.empty for com/rox/input/ControllerConfigLoader::findDevice → KILLED |
return match; |
| 192 | } | |
| 193 | ||
| 194 | //no name given - fall back to enumeration order, which is stabler within a single run than | |
| 195 | //across runs/OS-reboots, hence name-substring being the recommended, primary option above | |
| 196 | final int index = Integer.parseInt(properties.getProperty(prefix + ".gamepad.deviceIndex", "0")); | |
| 197 |
2
1. findDevice : negated conditional → KILLED 2. findDevice : changed conditional boundary → KILLED |
if (index < 0){ |
| 198 | throw new IllegalArgumentException("Gamepad deviceIndex " + index + " for " + prefix + " can't be negative"); | |
| 199 | } | |
| 200 |
2
1. findDevice : changed conditional boundary → KILLED 2. findDevice : negated conditional → KILLED |
if (index >= deviceList.size()){ |
| 201 |
1
1. findDevice : removed call to com/rox/input/ControllerConfigLoader::warnMissing → KILLED |
warnMissing("No connected gamepad at index " + index, prefix, deviceList); |
| 202 | return Optional.empty(); | |
| 203 | } | |
| 204 |
1
1. findDevice : replaced return value with Optional.empty for com/rox/input/ControllerConfigLoader::findDevice → KILLED |
return Optional.of(deviceList.get(index)); |
| 205 | } | |
| 206 | ||
| 207 | private static void warnMissing(final String problem, final String prefix, final List<InputDevice> connected){ | |
| 208 |
1
1. warnMissing : negated conditional → KILLED |
final String found = connected.isEmpty() |
| 209 | ? "none" | |
| 210 |
1
1. lambda$warnMissing$0 : replaced return value with "" for com/rox/input/ControllerConfigLoader::lambda$warnMissing$0 → KILLED |
: connected.stream().map(d -> "'" + d.getDisplayName() + "'").collect(Collectors.joining(", ")); |
| 211 | log.warning(problem + " for " + prefix + " (connected: " + found + ") - starting " + prefix + " with no controller"); | |
| 212 | } | |
| 213 | ||
| 214 | private static Map<Button, GamepadBinding> parseGamepadBindings(final Properties properties, final String prefix){ | |
| 215 | final Map<Button, GamepadBinding> bindings = new HashMap<>(); | |
| 216 | for (final Button button : Button.values()){ | |
| 217 | final String value = properties.getProperty(prefix + ".gamepad." + button.name().toLowerCase(Locale.ROOT)); | |
| 218 |
1
1. parseGamepadBindings : negated conditional → KILLED |
if (value != null){ |
| 219 | bindings.put(button, resolveGamepadBinding(value, prefix, button)); | |
| 220 | } | |
| 221 | } | |
| 222 |
1
1. parseGamepadBindings : replaced return value with Collections.emptyMap for com/rox/input/ControllerConfigLoader::parseGamepadBindings → KILLED |
return bindings; |
| 223 | } | |
| 224 | ||
| 225 | private static GamepadBinding resolveGamepadBinding(final String value, final String prefix, final Button button){ | |
| 226 | final String[] parts = value.split(":", 2); | |
| 227 | final String componentName = parts[0].trim(); | |
| 228 | final InputComponent.ID componentId = KNOWN_COMPONENTS.get(componentName); | |
| 229 |
1
1. resolveGamepadBinding : negated conditional → KILLED |
if (componentId == null){ |
| 230 | throw new IllegalArgumentException( | |
| 231 | "Unknown gamepad component '" + componentName + "' bound to " + button + " for " + prefix); | |
| 232 | } | |
| 233 |
1
1. resolveGamepadBinding : negated conditional → KILLED |
if (componentId.type != ComponentType.AXIS){ |
| 234 |
2
1. resolveGamepadBinding : changed conditional boundary → KILLED 2. resolveGamepadBinding : negated conditional → KILLED |
if (parts.length > 1){ |
| 235 | throw new IllegalArgumentException( | |
| 236 | "Button component '" + componentName + "' bound to " + button + " for " + prefix | |
| 237 | + " must not have a direction suffix"); | |
| 238 | } | |
| 239 |
1
1. resolveGamepadBinding : replaced return value with null for com/rox/input/ControllerConfigLoader::resolveGamepadBinding → KILLED |
return new GamepadBinding.ButtonBinding(componentId); |
| 240 | } | |
| 241 |
2
1. resolveGamepadBinding : negated conditional → KILLED 2. resolveGamepadBinding : changed conditional boundary → KILLED |
if (parts.length < 2){ |
| 242 | throw new IllegalArgumentException( | |
| 243 | "Axis component '" + componentName + "' bound to " + button + " for " + prefix | |
| 244 | + " needs a direction suffix, e.g. '" + componentName + ":positive'"); | |
| 245 | } | |
| 246 | final boolean positive = switch (parts[1].trim().toLowerCase(Locale.ROOT)) { | |
| 247 | case "positive" -> true; | |
| 248 | case "negative" -> false; | |
| 249 | default -> throw new IllegalArgumentException( | |
| 250 | "Unknown axis direction '" + parts[1] + "' bound to " + button + " for " + prefix | |
| 251 | + " - expected 'positive' or 'negative'"); | |
| 252 | }; | |
| 253 |
1
1. resolveGamepadBinding : replaced return value with null for com/rox/input/ControllerConfigLoader::resolveGamepadBinding → KILLED |
return new GamepadBinding.AxisBinding(componentId, positive, DEFAULT_AXIS_THRESHOLD); |
| 254 | } | |
| 255 | ||
| 256 | private static Map<Integer, Button> parseKeyBindings(final Properties properties, final String prefix){ | |
| 257 | final Map<Integer, Button> bindings = new HashMap<>(); | |
| 258 | for (final Button button : Button.values()){ | |
| 259 | final String keyName = properties.getProperty(prefix + ".keyboard." + button.name().toLowerCase()); | |
| 260 |
1
1. parseKeyBindings : negated conditional → KILLED |
if (keyName != null){ |
| 261 | bindings.put(resolveKeyCode(keyName), button); | |
| 262 | } | |
| 263 | } | |
| 264 |
1
1. parseKeyBindings : replaced return value with Collections.emptyMap for com/rox/input/ControllerConfigLoader::parseKeyBindings → KILLED |
return bindings; |
| 265 | } | |
| 266 | ||
| 267 | private static int resolveKeyCode(final String keyName){ | |
| 268 | try { | |
| 269 | //Locale.ROOT, not the JVM default - a Turkish default locale uppercases "i" to "İ" (dotted | |
| 270 | //capital I), which would look up a nonexistent "VK_İ..." field instead of "VK_I..." | |
| 271 |
1
1. resolveKeyCode : replaced int return with 0 for com/rox/input/ControllerConfigLoader::resolveKeyCode → KILLED |
return KeyEvent.class.getField("VK_" + keyName.toUpperCase(Locale.ROOT)).getInt(null); |
| 272 | } catch (NoSuchFieldException | IllegalAccessException e){ | |
| 273 | throw new IllegalArgumentException( | |
| 274 | "Unknown key name '" + keyName + "' - expected a java.awt.event.KeyEvent.VK_* field name", e); | |
| 275 | } | |
| 276 | } | |
| 277 | } | |
Mutations | ||
| 72 |
1.1 |
|
| 74 |
1.1 |
|
| 78 |
1.1 |
|
| 91 |
1.1 2.2 |
|
| 92 |
1.1 |
|
| 94 |
1.1 |
|
| 113 |
1.1 2.2 |
|
| 127 |
1.1 |
|
| 129 |
1.1 |
|
| 148 |
1.1 |
|
| 151 |
1.1 |
|
| 158 |
1.1 |
|
| 159 |
1.1 |
|
| 161 |
1.1 |
|
| 164 |
1.1 |
|
| 165 |
1.1 |
|
| 168 |
1.1 |
|
| 169 |
1.1 |
|
| 183 |
1.1 |
|
| 186 |
1.1 2.2 |
|
| 188 |
1.1 |
|
| 189 |
1.1 |
|
| 191 |
1.1 |
|
| 197 |
1.1 2.2 |
|
| 200 |
1.1 2.2 |
|
| 201 |
1.1 |
|
| 204 |
1.1 |
|
| 208 |
1.1 |
|
| 210 |
1.1 |
|
| 218 |
1.1 |
|
| 222 |
1.1 |
|
| 229 |
1.1 |
|
| 233 |
1.1 |
|
| 234 |
1.1 2.2 |
|
| 239 |
1.1 |
|
| 241 |
1.1 2.2 |
|
| 253 |
1.1 |
|
| 260 |
1.1 |
|
| 264 |
1.1 |
|
| 271 |
1.1 |