From a90f9635d06ae181b2edc9e50bd612c1ee4bbc97 Mon Sep 17 00:00:00 2001 From: TooManyTokens Date: Tue, 13 Oct 2026 01:03:57 -0400 Subject: [PATCH] Add Bumper Jumper and other controller presets Add Recon, Walkie Talkie, Fishstick and Helljumper alongside Bumper Jumper. Preserve saved preset ordinals, name the existing Boxer and Green Thumb swaps, and keep menu navigation unchanged. Guard stock layout previews and profile descriptions against the new preset indices. Document CE-specific adaptations and add asset-free mapping checks. Checked: controller preset tests and portable Windows release build. Physical controller gameplay and Android build were not tested. (cherry picked from commit 0f10d499e747139e5d5a147755e7ce8b53bc43f1) --- port/assets/menus/README.md | 6 + ...etup.player_profile_edit.gamepad_setup.xml | 4 +- port/linux/README.md | 41 +++++ port/linux/game/menu_functions.c | 14 +- source/interface/player_ui.c | 78 +++++++++ .../ui_widget_game_data_input_functions.c | 24 ++- source/saved games/player_profile.h | 6 + tools/port_settings.py | 20 ++- tools/test_controller_presets.py | 154 ++++++++++++++++++ 9 files changed, 335 insertions(+), 12 deletions(-) create mode 100644 tools/test_controller_presets.py diff --git a/port/assets/menus/README.md b/port/assets/menus/README.md index 3df78f18cb..31867e1d43 100644 --- a/port/assets/menus/README.md +++ b/port/assets/menus/README.md @@ -62,6 +62,12 @@ A settings screen (Controls Setup, Gamepads, Mouse, Audio, Video or Network Setup) is changed in `tools/port_settings.py`, not in its XML, and written with the command above. +To regenerate only one port settings screen without the external tag assets: + +```sh +python tools/port_settings.py gamepad_setup +``` + ## Elements A file holds one `` element (`root` names the main menu's widget, in diff --git a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.xml b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.xml index c6049587fd..2ce60ecaa5 100644 --- a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.xml +++ b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.xml @@ -70,7 +70,7 @@ - + @@ -126,7 +126,7 @@ - + diff --git a/port/linux/README.md b/port/linux/README.md index 3c3f117735..a8eb9ff666 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -193,6 +193,47 @@ controller 1. The gamepad changes controller only when none of its buttons is held. The profile's button layout (Settings > Gamepads) is the gamepads' only. +Settings > Gamepads > Button Layout offers BUMPER JUMPER, based on Halo 3. +It is saved in the player profile. Menus keep A to accept and B to go back. + +| Button | Bumper Jumper action | +| --- | --- | +| LB | Jump | +| RB | Melee | +| LT | Throw grenade | +| RT | Fire | +| A | Switch grenades | +| B | Reload / use (hold to swap weapons) | +| X | Flashlight (Halo CE has no equipment) | +| Y | Switch weapons | +| Left stick click | Crouch | +| Right stick click | Zoom | +| Start / Back | Pause / scores | + +Other layouts include classic BOXER (LT melee, B grenade) and GREEN THUMB +(right stick click melee, B zoom), named versions of the existing swaps. +The later-game layouts below are adapted to CE's actions. They keep RT fire, +Y switch weapons, Start pause and Back scores. Stick layout is independent. + +| Action | Recon | Walkie Talkie | Fishstick | Helljumper | +| --- | --- | --- | --- | --- | +| Jump | A | A | A | LB | +| Melee | B | RB | Right stick click | Right stick click | +| Reload / use | RB | B | X | B | +| Throw grenade | LT | LT | RB | RB | +| Switch grenades | LB | X | LB | A | +| Flashlight | X | LB | Left stick click | X | +| Crouch | Left stick click | Left stick click | B | Left stick click | +| Zoom | Right stick click | Right stick click | LT | LT | + +Recon follows Halo 3's default layout, with X flashlight instead of equipment. +Walkie Talkie's LB operates the flashlight; it does not bind voice chat. +Fishstick and Helljumper use CE's toggle/cycle zoom, not hold-to-aim. CE has +no sprint, equipment or Spartan abilities; the remaining buttons serve its +flashlight and grenade selection. Layout references: +[Halo 3](https://www.halopedia.org/H3:Control_schemes) and +[Halo 5](https://www.halopedia.org/H5G:Control_schemes). + | Action | Keys and buttons (default) | | --- | --- | | move | W, A, S, D | diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index 8bf724b8d4..cc2fd6656c 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -1626,6 +1626,7 @@ BITMAP_FRAMES): Gamepad Setup's row's, as the list numbers its rows the PC version's nine the Xbox's five of the button settings */ #define PROFILE_GAMEPAD_FRAME 2 #define PROFILE_FIRST_LAYOUT_FRAME 9 +#define PROFILE_LAYOUT_PICTURES 5 /* "port gamepad layout preview" (the picture's own): on Gamepad Setup's row, the edited profile's button settings */ @@ -1635,11 +1636,14 @@ static void profile_gamepad_layout(struct widget_instance *picture) short frame = picture->animation.current_frame_index; if (frame == PROFILE_GAMEPAD_FRAME || - (frame >= PROFILE_FIRST_LAYOUT_FRAME && frame < PROFILE_FIRST_LAYOUT_FRAME + NUMBER_OF_BUTTON_PRESETS)) - { - picture->animation.current_frame_index = (short)(PROFILE_FIRST_LAYOUT_FRAME + - (profile && profile->controller_settings.button_preset < NUMBER_OF_BUTTON_PRESETS ? - profile->controller_settings.button_preset : _button_preset_standard)); + (frame >= PROFILE_FIRST_LAYOUT_FRAME && frame < PROFILE_FIRST_LAYOUT_FRAME + PROFILE_LAYOUT_PICTURES)) + { + /* The Xbox bitmap has only the original five layouts. New layouts + use the PC gamepad picture rather than an absent bitmap frame. */ + picture->animation.current_frame_index = profile && + profile->controller_settings.button_preset >= PROFILE_LAYOUT_PICTURES ? + PROFILE_GAMEPAD_FRAME : (short)(PROFILE_FIRST_LAYOUT_FRAME + + (profile ? profile->controller_settings.button_preset : _button_preset_standard)); } } diff --git a/source/interface/player_ui.c b/source/interface/player_ui.c index 3f33299e7a..a7553336a4 100644 --- a/source/interface/player_ui.c +++ b/source/interface/player_ui.c @@ -1169,6 +1169,84 @@ static void set_local_player_controls_from_player_profile( preferences.game_control_to_xbox_buttons[10] = _gamepad_binary_button_left_thumb; preferences.game_control_to_xbox_buttons[11] = _gamepad_analog_button_b; break; + /* port: Halo 3 Bumper Jumper; white/black are SDL's LB/RB. + CE has no equipment, so X operates the flashlight. */ + case _button_preset_bumper_jumper: + preferences.game_control_to_xbox_buttons[0] = _gamepad_analog_button_white; + preferences.game_control_to_xbox_buttons[1] = _gamepad_analog_button_a; + preferences.game_control_to_xbox_buttons[2] = _gamepad_analog_button_b; + preferences.game_control_to_xbox_buttons[3] = _gamepad_analog_button_y; + preferences.game_control_to_xbox_buttons[4] = _gamepad_analog_button_black; + preferences.game_control_to_xbox_buttons[5] = _gamepad_analog_button_x; + preferences.game_control_to_xbox_buttons[6] = _gamepad_analog_button_left_trigger; + preferences.game_control_to_xbox_buttons[7] = _gamepad_analog_button_right_trigger; + preferences.game_control_to_xbox_buttons[8] = _gamepad_binary_button_start; + preferences.game_control_to_xbox_buttons[9] = _gamepad_binary_button_back; + preferences.game_control_to_xbox_buttons[10] = _gamepad_binary_button_left_thumb; + preferences.game_control_to_xbox_buttons[11] = _gamepad_binary_button_right_thumb; + break; + /* port: Halo 3's default (later named Recon), with X flashlight. */ + case _button_preset_recon: + preferences.game_control_to_xbox_buttons[0] = _gamepad_analog_button_a; + preferences.game_control_to_xbox_buttons[1] = _gamepad_analog_button_white; + preferences.game_control_to_xbox_buttons[2] = _gamepad_analog_button_black; + preferences.game_control_to_xbox_buttons[3] = _gamepad_analog_button_y; + preferences.game_control_to_xbox_buttons[4] = _gamepad_analog_button_b; + preferences.game_control_to_xbox_buttons[5] = _gamepad_analog_button_x; + preferences.game_control_to_xbox_buttons[6] = _gamepad_analog_button_left_trigger; + preferences.game_control_to_xbox_buttons[7] = _gamepad_analog_button_right_trigger; + preferences.game_control_to_xbox_buttons[8] = _gamepad_binary_button_start; + preferences.game_control_to_xbox_buttons[9] = _gamepad_binary_button_back; + preferences.game_control_to_xbox_buttons[10] = _gamepad_binary_button_left_thumb; + preferences.game_control_to_xbox_buttons[11] = _gamepad_binary_button_right_thumb; + break; + /* port: Walkie Talkie's flashlight binding; voice chat stays separate. */ + case _button_preset_walkie_talkie: + preferences.game_control_to_xbox_buttons[0] = _gamepad_analog_button_a; + preferences.game_control_to_xbox_buttons[1] = _gamepad_analog_button_x; + preferences.game_control_to_xbox_buttons[2] = _gamepad_analog_button_b; + preferences.game_control_to_xbox_buttons[3] = _gamepad_analog_button_y; + preferences.game_control_to_xbox_buttons[4] = _gamepad_analog_button_black; + preferences.game_control_to_xbox_buttons[5] = _gamepad_analog_button_white; + preferences.game_control_to_xbox_buttons[6] = _gamepad_analog_button_left_trigger; + preferences.game_control_to_xbox_buttons[7] = _gamepad_analog_button_right_trigger; + preferences.game_control_to_xbox_buttons[8] = _gamepad_binary_button_start; + preferences.game_control_to_xbox_buttons[9] = _gamepad_binary_button_back; + preferences.game_control_to_xbox_buttons[10] = _gamepad_binary_button_left_thumb; + preferences.game_control_to_xbox_buttons[11] = _gamepad_binary_button_right_thumb; + break; + /* port: Fishstick's LT zoom/RB grenade/RS melee; CE has no sprint, + so LS operates the flashlight and LB switches grenades. */ + case _button_preset_fishstick: + preferences.game_control_to_xbox_buttons[0] = _gamepad_analog_button_a; + preferences.game_control_to_xbox_buttons[1] = _gamepad_analog_button_white; + preferences.game_control_to_xbox_buttons[2] = _gamepad_analog_button_x; + preferences.game_control_to_xbox_buttons[3] = _gamepad_analog_button_y; + preferences.game_control_to_xbox_buttons[4] = _gamepad_binary_button_right_thumb; + preferences.game_control_to_xbox_buttons[5] = _gamepad_binary_button_left_thumb; + preferences.game_control_to_xbox_buttons[6] = _gamepad_analog_button_black; + preferences.game_control_to_xbox_buttons[7] = _gamepad_analog_button_right_trigger; + preferences.game_control_to_xbox_buttons[8] = _gamepad_binary_button_start; + preferences.game_control_to_xbox_buttons[9] = _gamepad_binary_button_back; + preferences.game_control_to_xbox_buttons[10] = _gamepad_analog_button_b; + preferences.game_control_to_xbox_buttons[11] = _gamepad_analog_button_left_trigger; + break; + /* port: Helljumper's LB jump/LT zoom/RS melee/RB grenade, with + A switching grenades and X operating the flashlight. */ + case _button_preset_helljumper: + preferences.game_control_to_xbox_buttons[0] = _gamepad_analog_button_white; + preferences.game_control_to_xbox_buttons[1] = _gamepad_analog_button_a; + preferences.game_control_to_xbox_buttons[2] = _gamepad_analog_button_b; + preferences.game_control_to_xbox_buttons[3] = _gamepad_analog_button_y; + preferences.game_control_to_xbox_buttons[4] = _gamepad_binary_button_right_thumb; + preferences.game_control_to_xbox_buttons[5] = _gamepad_analog_button_x; + preferences.game_control_to_xbox_buttons[6] = _gamepad_analog_button_black; + preferences.game_control_to_xbox_buttons[7] = _gamepad_analog_button_right_trigger; + preferences.game_control_to_xbox_buttons[8] = _gamepad_binary_button_start; + preferences.game_control_to_xbox_buttons[9] = _gamepad_binary_button_back; + preferences.game_control_to_xbox_buttons[10] = _gamepad_binary_button_left_thumb; + preferences.game_control_to_xbox_buttons[11] = _gamepad_analog_button_left_trigger; + break; } preferences.invert_look = controls->invert_look; diff --git a/source/interface/ui_widget_game_data_input_functions.c b/source/interface/ui_widget_game_data_input_functions.c index b39fd464dd..da48ce1eeb 100644 --- a/source/interface/ui_widget_game_data_input_functions.c +++ b/source/interface/ui_widget_game_data_input_functions.c @@ -3908,7 +3908,17 @@ static void player_profile_1wide_list_update( profile->controller_settings.joystick_preset); wchar_t const *button_description = unicode_string_list_get_string( button_descriptions_tag_index, - profile->controller_settings.button_preset); + profile->controller_settings.button_preset >= _button_preset_bumper_jumper ? + _button_preset_standard : profile->controller_settings.button_preset); + /* port: the Xbox string list has only the original presets. */ + switch (profile->controller_settings.button_preset) + { + case _button_preset_bumper_jumper: button_description = L"Bumper Jumper"; break; + case _button_preset_recon: button_description = L"Recon"; break; + case _button_preset_walkie_talkie: button_description = L"Walkie Talkie"; break; + case _button_preset_fishstick: button_description = L"Fishstick"; break; + case _button_preset_helljumper: button_description = L"Helljumper"; break; + } usnprintf( profile_description->parameters.text_box.text, @@ -3939,7 +3949,17 @@ static void player_profile_1wide_list_update( profile->controller_settings.joystick_preset); wchar_t const *button_description = unicode_string_list_get_string( button_descriptions_tag_index, - profile->controller_settings.button_preset); + profile->controller_settings.button_preset >= _button_preset_bumper_jumper ? + _button_preset_standard : profile->controller_settings.button_preset); + /* port: the Xbox string list has only the original presets. */ + switch (profile->controller_settings.button_preset) + { + case _button_preset_bumper_jumper: button_description = L"Bumper Jumper"; break; + case _button_preset_recon: button_description = L"Recon"; break; + case _button_preset_walkie_talkie: button_description = L"Walkie Talkie"; break; + case _button_preset_fishstick: button_description = L"Fishstick"; break; + case _button_preset_helljumper: button_description = L"Helljumper"; break; + } usnprintf( profile_description->parameters.text_box.text, diff --git a/source/saved games/player_profile.h b/source/saved games/player_profile.h index 515a0991ff..4632e7c923 100644 --- a/source/saved games/player_profile.h +++ b/source/saved games/player_profile.h @@ -37,6 +37,12 @@ enum _button_preset_swap_a_and_left_trigger, _button_preset_swap_b_and_left_trigger, _button_preset_swap_b_and_right_thumb, + _button_preset_bumper_jumper, /* port: appended to preserve saved preset values */ + /* port: later-game layouts adapted to CE's actions */ + _button_preset_recon, + _button_preset_walkie_talkie, + _button_preset_fishstick, + _button_preset_helljumper, NUMBER_OF_BUTTON_PRESETS }; diff --git a/tools/port_settings.py b/tools/port_settings.py index 6e8028f85d..6a2fe374ca 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -179,9 +179,10 @@ ("AUTO-CENTER LOOK:", "profile.autocenter", YES_NO, "Level the view while walking.", None), ("BUTTON LAYOUT:", "profile.button_preset", - [("DEFAULT", "0"), ("SWAP TRIGGERS", "1"), ("SWAP A, L TRIGGER", "2"), ("SWAP B, L TRIGGER", "3"), - ("SWAP B, R STICK", "4")], - "Which of the controller's buttons does what (not\nthe keyboard's: Controls Setup sets those).", None), + [("DEFAULT", "0"), ("SWAP TRIGGERS", "1"), ("SWAP A, L TRIGGER", "2"), ("BOXER", "3"), + ("GREEN THUMB", "4"), ("BUMPER JUMPER", "5"), ("RECON", "6"), ("WALKIE TALKIE", "7"), + ("FISHSTICK", "8"), ("HELLJUMPER", "9")], + "Controller layouts adapted to Halo CE. Fishstick\nand Helljumper use LT toggle zoom. Menus keep A/B.", None), ("STICK LAYOUT:", "profile.joystick_preset", [("DEFAULT", "0"), ("SOUTHPAW", "1"), ("LEGACY", "2"), ("LEGACY SOUTHPAW", "3")], "Which stick moves and which looks.", None), @@ -1460,3 +1461,16 @@ def multiplayer_files() -> dict: REPLACED_FOLDERS = [f"{MT}/server_settings"] + + +if __name__ == "__main__": + import argparse + from pathlib import Path + + parser = argparse.ArgumentParser(description="Regenerate one port settings screen without external tag assets.") + parser.add_argument("screen", choices=SCREENS) + args = parser.parse_args() + filename = f"{PE}/{args.screen}".replace("/", ".") + ".xml" + destination = Path(__file__).resolve().parents[1] / "port/assets/menus/ce" / filename + destination.write_text("\n".join(settings_files()[filename]), encoding="utf-8") + print(destination) diff --git a/tools/test_controller_presets.py b/tools/test_controller_presets.py new file mode 100644 index 0000000000..7be2eb26ff --- /dev/null +++ b/tools/test_controller_presets.py @@ -0,0 +1,154 @@ +"""Asset-free controller profile and menu preview checks against the engine's code.""" + +import os +import subprocess +import tempfile +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + +from harness import enum_with, function, read, structure +from port_settings import PE, settings_files + +ROOT = Path(__file__).resolve().parents[1] + + +class ControllerPresets(unittest.TestCase): + def test_profile_controls_and_preview(self): + profile_header = read("source/saved games/player_profile.h") + inputs = read("source/input/input.h") + menu = read("port/linux/game/menu_functions.c") + constants = "\n".join(line for line in menu.splitlines() if line.startswith("#define PROFILE_")) + declarations = "\n".join([ + enum_with(profile_header, "_button_preset_standard"), + enum_with(profile_header, "_joystick_preset_standard"), + enum_with(inputs, "_gamepad_analog_button_a"), + structure(profile_header, "player_profile_controller_settings"), + structure(read("source/input/input_abstraction.h"), "game_input_preferences"), + ]) + code = """ +typedef unsigned char byte; +typedef unsigned char boolean; +typedef float real; +#define NONE (-1) +#define MAXIMUM_NUMBER_OF_LOCAL_PLAYERS 4 +#define NUMBER_OF_LOOK_SENSITIVITY_SETTINGS 10 +#define match_assert(file, line, condition) ((void)0) +""" + declarations + """ +struct player_profile { struct player_profile_controller_settings controller_settings; }; +static struct { + struct { struct player_profile profile; } local_players[4]; + short single_player_controller[4]; +} player_ui_globals; +static struct game_input_preferences captured; +static short captured_controller; +static void input_abstraction_update_local_player_preferences(short controller, + struct game_input_preferences const *preferences) +{ + captured = *preferences; + captured_controller = controller; +} +struct widget_instance { struct { short current_frame_index; } animation; }; +static struct player_profile *edited; +static struct player_profile *player_ui_get_edit_player_profile(void) { return edited; } +""" + constants + "\n" + function(read("source/interface/player_ui.c"), + "set_local_player_controls_from_player_profile") + code += "\n" + function(menu, "profile_gamepad_layout") + """ +int main(void) +{ + /* Physical Xbox button indices, in game-action order. Old presets must + retain their saved ordinals; later-game layouts follow them. */ + static byte const expected[10][12] = { + {0,4,2,3,1,5,6,7,12,13,14,15}, + {0,4,2,3,1,5,7,6,12,13,14,15}, + {6,4,2,3,1,5,0,7,12,13,14,15}, + {0,4,2,3,6,5,1,7,12,13,14,15}, + {0,4,2,3,15,5,6,7,12,13,14,1}, + {5,0,1,3,4,2,6,7,12,13,14,15}, + {0,5,4,3,1,2,6,7,12,13,14,15}, + {0,2,1,3,4,5,6,7,12,13,14,15}, + {0,5,2,3,15,14,4,7,12,13,1,6}, + {5,0,1,3,15,2,4,7,12,13,14,6} + }; + struct player_profile_controller_settings *controls; + struct widget_instance picture; + int preset, player, action; + if (NUMBER_OF_BUTTON_PRESETS != 10 || _button_preset_bumper_jumper != 5 || + _button_preset_recon != 6 || _button_preset_walkie_talkie != 7 || + _button_preset_fishstick != 8 || _button_preset_helljumper != 9) return 1; + for (player = 0; player < 4; player++) { + controls = &player_ui_globals.local_players[player].profile.controller_settings; + controls->look_sensitivity = 3; + controls->joystick_preset = _joystick_preset_south_paw; + controls->invert_look = 1; + controls->flight_stick_aircraft_controls = 1; + player_ui_globals.single_player_controller[player] = NONE; + for (preset = 0; preset < 10; preset++) { + controls->button_preset = preset; + set_local_player_controls_from_player_profile(player); + if (captured_controller != player) return 2; + for (action = 0; action < 12; action++) + if (captured.game_control_to_xbox_buttons[action] != expected[preset][action]) return 3; + /* Every action must remain reachable on a distinct physical button. */ + for (action = 0; action < 12; action++) { + int other; + for (other = action + 1; other < 12; other++) + if (captured.game_control_to_xbox_buttons[action] == + captured.game_control_to_xbox_buttons[other]) return 9; + } + if (captured.pitch_rate != 60 || captured.yaw_rate != 120 || + captured.joystick_controls != _joystick_preset_south_paw || + !captured.invert_look || !captured.invert_look_aircraft_control) return 4; + } + player_ui_globals.single_player_controller[player] = (player + 1) % 4; + set_local_player_controls_from_player_profile(player); + if (captured_controller != (player + 1) % 4) return 5; + } + edited = &player_ui_globals.local_players[0].profile; + picture.animation.current_frame_index = PROFILE_GAMEPAD_FRAME; + for (preset = 0; preset < 10; preset++) { + edited->controller_settings.button_preset = preset; + profile_gamepad_layout(&picture); + if (picture.animation.current_frame_index != + (preset >= 5 ? PROFILE_GAMEPAD_FRAME : PROFILE_FIRST_LAYOUT_FRAME + preset)) return 6; + } + edited->controller_settings.button_preset = 0; + profile_gamepad_layout(&picture); + if (picture.animation.current_frame_index != PROFILE_FIRST_LAYOUT_FRAME) return 7; + picture.animation.current_frame_index = 1; + profile_gamepad_layout(&picture); + if (picture.animation.current_frame_index != 1) return 8; + return 0; +} +""" + with tempfile.TemporaryDirectory(prefix="halo-controller-") as directory: + source = Path(directory) / "controls.c" + executable = Path(directory) / ("controls.exe" if os.name == "nt" else "controls") + source.write_text(code, encoding="utf-8") + command = [os.environ.get("CC", "clang"), "-std=gnu99", "-fuse-ld=lld"] + if os.name != "nt": + command += ["--no-default-config", "-m32"] + sysroot = ROOT / "build/linux/third_party/sysroot" + if sysroot.is_dir(): + command.append(f"--sysroot={sysroot}") + result = subprocess.run(command + [str(source), "-o", str(executable)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(executable)], timeout=30) + self.assertEqual(result.returncode, 0) + + def test_generated_menu_matches_generator(self): + filename = f"{PE}/gamepad_setup".replace("/", ".") + ".xml" + generated = "\n".join(settings_files()[filename]) + self.assertEqual((ROOT / "port/assets/menus/ce" / filename).read_text(), generated) + root = ET.fromstring(generated) + spinner = next(widget for widget in root.iter("widget") + if widget.get("setting") == "profile.button_preset") + choices = dict(zip(spinner.get("strings").split("|"), spinner.get("values").split("|"))) + for name, value in [("BOXER", "3"), ("GREEN THUMB", "4"), ("BUMPER JUMPER", "5"), + ("RECON", "6"), ("WALKIE TALKIE", "7"), ("FISHSTICK", "8"), ("HELLJUMPER", "9")]: + self.assertEqual(choices[name], value) + self.assertIn('event="a" run="port settings save"', generated) + + +if __name__ == "__main__": + unittest.main()