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()