From ae3be4a3d2de62361a5f4645ceb67ef55a2402bd Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Tue, 13 Oct 2026 04:16:26 -0700 Subject: [PATCH 1/2] Add per-axis joystick deadzone and sensitivity options Adds Heading, Pitch, Bank and Relative Throttle deadzone and sensitivity options. Each defaults to Use Global, which keeps the existing global Deadzone and Sensitivity settings; a value set for an axis action overrides them for whatever stick axis is bound to that action, and an axis deadzone also takes precedence over -deadzone. Absolute throttle is unaffected because it never used a deadzone or sensitivity. Addresses #4480 --- code/controlconfig/controlsconfig.cpp | 19 ++- code/controlconfig/controlsconfig.h | 8 +- code/controlconfig/controlsconfigcommon.cpp | 84 ++++++++++ test/src/controlconfig/test_joy_scaling.cpp | 177 ++++++++++++++++++++ test/src/source_groups.cmake | 4 + 5 files changed, 284 insertions(+), 8 deletions(-) create mode 100644 test/src/controlconfig/test_joy_scaling.cpp diff --git a/code/controlconfig/controlsconfig.cpp b/code/controlconfig/controlsconfig.cpp index 3b6a4938cce..bb47d58a752 100644 --- a/code/controlconfig/controlsconfig.cpp +++ b/code/controlconfig/controlsconfig.cpp @@ -454,14 +454,21 @@ static int joy_get_unscaled_reading(int raw) return (int) ((std::uint64_t) raw * (std::uint64_t) F1_0 / (std::uint64_t) rng); // convert to 0 - F1_0 range, 64bit ints used to avoid uint overflow } -int joy_get_scaled_reading(int raw) +int joy_get_scaled_reading(int raw, int action) { int x, d, dead_zone, rng; float percent, sensitivity_percent, non_sensitivity_percent; + const bool per_axis = action >= 0 && action < Action::NUM_VALUES; + const int axis_dead_zone = per_axis ? Joy_axis_dead_zone_size[action] : -1; + const int sensitivity = + (per_axis && Joy_axis_sensitivity[action] >= 0) ? Joy_axis_sensitivity[action] : Joy_sensitivity; + raw -= JOY_AXIS_CENTER; - if (Cmdline_deadzone >= 0) { + if (axis_dead_zone >= 0) { + dead_zone = (JOY_AXIS_MAX - JOY_AXIS_MIN) * axis_dead_zone / 100; + } else if (Cmdline_deadzone >= 0) { dead_zone = (JOY_AXIS_MAX - JOY_AXIS_MIN) * Cmdline_deadzone / 200; //Making this div by 200 is what allows us to have the granularity of 0 to 100 in the cmdline. //This allows a larger deadzone than the original maximum, all the way to the stick's full range. @@ -481,11 +488,11 @@ int joy_get_scaled_reading(int raw) if (d > rng) d = rng; - Assert(Joy_sensitivity >= 0 && Joy_sensitivity <= 9); + Assert(sensitivity >= 0 && sensitivity <= 9); // compute percentages as a range between 0 and 1 - sensitivity_percent = (float) Joy_sensitivity / 9.0f; - non_sensitivity_percent = (float) (9 - Joy_sensitivity) / 9.0f; + sensitivity_percent = (float) sensitivity / 9.0f; + non_sensitivity_percent = (float) (9 - sensitivity) / 9.0f; // find percent of max axis is at percent = (float) d / (float) rng; @@ -3055,7 +3062,7 @@ void scale_invert(const CC_bind &bind, case CC_TYPE_AXIS_BTN_NEG: case CC_TYPE_AXIS_BTN_POS: default: - dx = joy_get_scaled_reading(axis_in[cid][btn]); + dx = joy_get_scaled_reading(axis_in[cid][btn], action); break; } diff --git a/code/controlconfig/controlsconfig.h b/code/controlconfig/controlsconfig.h index 24e538d6882..7a1b4cf7cf9 100644 --- a/code/controlconfig/controlsconfig.h +++ b/code/controlconfig/controlsconfig.h @@ -698,6 +698,8 @@ extern int Failed_key_index; extern int Joy_dead_zone_size; extern int Joy_sensitivity; +extern int Joy_axis_dead_zone_size[Action::NUM_VALUES]; +extern int Joy_axis_sensitivity[Action::NUM_VALUES]; extern int Control_config_overlay_id; @@ -1037,11 +1039,13 @@ void control_config_clear_used_status(); /** * @brief Applies sensitivity multiplier and deadzone histerisis to the raw axis value * - * @param[in] raw The raw axis value to transform + * @param[in] raw The raw axis value to transform + * @param[in] action The axis action (Action::HEADING and so on) whose own deadzone and sensitivity apply when set, + * or -1 for the global ones * * @return The transformed value */ -int joy_get_scaled_reading(int raw); +int joy_get_scaled_reading(int raw, int action = -1); /** * Lookups the given stringified enum to find its value diff --git a/code/controlconfig/controlsconfigcommon.cpp b/code/controlconfig/controlsconfigcommon.cpp index 5a01816a19c..3b77e5c3b6f 100644 --- a/code/controlconfig/controlsconfigcommon.cpp +++ b/code/controlconfig/controlsconfigcommon.cpp @@ -73,6 +73,90 @@ auto SensitivityOption = options::OptionBuilder("Input.JoystickSensitivity" .flags({options::OptionFlags::RetailBuiltinOption}) .finish(); +// -1 means "use the global setting above" +static_assert(Action::NUM_VALUES == 5, "give every axis action a -1 entry below"); +int Joy_axis_dead_zone_size[Action::NUM_VALUES] = {-1, -1, -1, -1, -1}; +int Joy_axis_sensitivity[Action::NUM_VALUES] = {-1, -1, -1, -1, -1}; + +static SCP_string axis_setting_display(const int& val) +{ + return val < 0 ? SCP_string("Use Global") : std::to_string(val); +} + +static std::shared_ptr> +make_axis_option(const char* key, const char* title, const char* description, int max, int* dest, int importance) +{ + return options::OptionBuilder(key, + std::pair{title, -1}, + std::pair{description, -1}) + .category(std::make_pair("Input", 1827)) + .range(-1, max) + .level(options::ExpertLevel::Advanced) + .default_val(-1) + .display(axis_setting_display) + .flags({options::OptionFlags::RangeTypeInteger}) + .bind_to(dest) + .importance(importance) + .finish(); +} + +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto HeadingDeadZoneOption __UNUSED = make_axis_option("Input.HeadingDeadZone", + "Heading Deadzone", + "The deadzone for the axis bound to heading; Use Global follows Deadzone", + 45, + &Joy_axis_dead_zone_size[Action::HEADING], + 3); +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto PitchDeadZoneOption __UNUSED = make_axis_option("Input.PitchDeadZone", + "Pitch Deadzone", + "The deadzone for the axis bound to pitch; Use Global follows Deadzone", + 45, + &Joy_axis_dead_zone_size[Action::PITCH], + 4); +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto BankDeadZoneOption __UNUSED = make_axis_option("Input.BankDeadZone", + "Bank Deadzone", + "The deadzone for the axis bound to bank; Use Global follows Deadzone", + 45, + &Joy_axis_dead_zone_size[Action::BANK], + 5); +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto RelThrottleDeadZoneOption __UNUSED = make_axis_option("Input.RelThrottleDeadZone", + "Relative Throttle Deadzone", + "The deadzone for the axis bound to relative throttle; Use Global follows Deadzone", + 45, + &Joy_axis_dead_zone_size[Action::REL_THROTTLE], + 6); +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto HeadingSensitivityOption __UNUSED = make_axis_option("Input.HeadingSensitivity", + "Heading Sensitivity", + "The sensitivity for the axis bound to heading; Use Global follows Sensitivity", + 9, + &Joy_axis_sensitivity[Action::HEADING], + 7); +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto PitchSensitivityOption __UNUSED = make_axis_option("Input.PitchSensitivity", + "Pitch Sensitivity", + "The sensitivity for the axis bound to pitch; Use Global follows Sensitivity", + 9, + &Joy_axis_sensitivity[Action::PITCH], + 8); +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto BankSensitivityOption __UNUSED = make_axis_option("Input.BankSensitivity", + "Bank Sensitivity", + "The sensitivity for the axis bound to bank; Use Global follows Sensitivity", + 9, + &Joy_axis_sensitivity[Action::BANK], + 9); +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto RelThrottleSensitivityOption __UNUSED = make_axis_option("Input.RelThrottleSensitivity", + "Relative Throttle Sensitivity", + "The sensitivity for the axis bound to relative throttle; Use Global follows Sensitivity", + 9, + &Joy_axis_sensitivity[Action::REL_THROTTLE], + 10); + //! arrays which hold the key mappings. The array index represents a key-independent action. //! please use SPACES for aligning the fields of this array //! When adding new controls, order that they show up is dependant on their location in IoActionId, not on their hardcoded locations here diff --git a/test/src/controlconfig/test_joy_scaling.cpp b/test/src/controlconfig/test_joy_scaling.cpp new file mode 100644 index 00000000000..73ced1bd175 --- /dev/null +++ b/test/src/controlconfig/test_joy_scaling.cpp @@ -0,0 +1,177 @@ +#include "cmdline/cmdline.h" +#include "controlconfig/controlsconfig.h" +#include "io/joy.h" +#include "options/Option.h" +#include "options/OptionsManager.h" + +#include + +// not in a header; defined in controlsconfig.cpp +void scale_invert(const CC_bind& bind, + int action, + CC_type type, + float frame_time, + int (&axis_in)[CID_JOY_MAX + 1][JOY_NUM_AXES], + int* axis_out); + +class JoyScalingTest : public ::testing::Test { + protected: + void SetUp() override + { + saved_dead_zone = Joy_dead_zone_size; + saved_sensitivity = Joy_sensitivity; + saved_cmdline_dead_zone = Cmdline_deadzone; + for (int i = 0; i < Action::NUM_VALUES; ++i) { + saved_axis_dead_zone[i] = Joy_axis_dead_zone_size[i]; + saved_axis_sensitivity[i] = Joy_axis_sensitivity[i]; + Joy_axis_dead_zone_size[i] = -1; + Joy_axis_sensitivity[i] = -1; + } + Joy_dead_zone_size = 10; + Joy_sensitivity = 9; + Cmdline_deadzone = -1; + } + + void TearDown() override + { + // also here, so a failed assertion can't leave an override behind for later tests + options::OptionsManager::instance()->clearOverrides(); + Joy_dead_zone_size = saved_dead_zone; + Joy_sensitivity = saved_sensitivity; + Cmdline_deadzone = saved_cmdline_dead_zone; + for (int i = 0; i < Action::NUM_VALUES; ++i) { + Joy_axis_dead_zone_size[i] = saved_axis_dead_zone[i]; + Joy_axis_sensitivity[i] = saved_axis_sensitivity[i]; + } + } + + int saved_dead_zone = 0; + int saved_sensitivity = 0; + int saved_cmdline_dead_zone = 0; + int saved_axis_dead_zone[Action::NUM_VALUES] = {}; + int saved_axis_sensitivity[Action::NUM_VALUES] = {}; +}; + +// 10% of the axis range is 6553 units, so 5000 off center sits inside the global deadzone and 5% (3276) is not. +TEST_F(JoyScalingTest, AxisDeadZoneOverridesGlobal) +{ + const int raw = JOY_AXIS_CENTER + 5000; + ASSERT_EQ(joy_get_scaled_reading(raw), 0); + ASSERT_EQ(joy_get_scaled_reading(raw, Action::BANK), 0); + + Joy_axis_dead_zone_size[Action::BANK] = 5; + ASSERT_GT(joy_get_scaled_reading(raw, Action::BANK), 0); + ASSERT_EQ(joy_get_scaled_reading(raw, Action::HEADING), 0); + ASSERT_EQ(joy_get_scaled_reading(raw), 0); + + Joy_axis_dead_zone_size[Action::HEADING] = 0; + ASSERT_GT(joy_get_scaled_reading(JOY_AXIS_CENTER + 1, Action::HEADING), 0); +} + +TEST_F(JoyScalingTest, AxisDeadZoneWinsOverCommandLine) +{ + const int raw = JOY_AXIS_CENTER + 5000; + Cmdline_deadzone = 0; + ASSERT_GT(joy_get_scaled_reading(raw, Action::PITCH), 0); + + Joy_axis_dead_zone_size[Action::PITCH] = 20; + ASSERT_EQ(joy_get_scaled_reading(raw, Action::PITCH), 0); +} + +TEST_F(JoyScalingTest, AxisSensitivityOverridesGlobal) +{ + const int raw = JOY_AXIS_CENTER + JOY_AXIS_RANGE / 2; + const int global = joy_get_scaled_reading(raw, Action::PITCH); + + Joy_axis_sensitivity[Action::PITCH] = 0; + const int low = joy_get_scaled_reading(raw, Action::PITCH); + ASSERT_LT(low, global); + ASSERT_EQ(joy_get_scaled_reading(raw, Action::HEADING), global); + + Joy_sensitivity = 0; + ASSERT_EQ(joy_get_scaled_reading(raw), low); + Joy_axis_sensitivity[Action::PITCH] = 9; + ASSERT_EQ(joy_get_scaled_reading(raw, Action::PITCH), global); +} + +TEST_F(JoyScalingTest, NegativeDeflectionIsSymmetric) +{ + Joy_axis_dead_zone_size[Action::BANK] = 5; + Joy_axis_sensitivity[Action::BANK] = 4; + const int offset = JOY_AXIS_RANGE / 3; + ASSERT_EQ(joy_get_scaled_reading(JOY_AXIS_CENTER - offset, Action::BANK), + -joy_get_scaled_reading(JOY_AXIS_CENTER + offset, Action::BANK)); +} + +TEST_F(JoyScalingTest, AxisDeadZoneUsesTheGlobalScale) +{ + for (int dz : {0, 10, 20, 45}) { + for (int offset : {1000, 5000, 9000, 15000, 30000}) { + Joy_dead_zone_size = dz; + const int global = joy_get_scaled_reading(JOY_AXIS_CENTER + offset); + Joy_dead_zone_size = 10; + Joy_axis_dead_zone_size[Action::HEADING] = dz; + ASSERT_EQ(joy_get_scaled_reading(JOY_AXIS_CENTER + offset, Action::HEADING), global) + << "deadzone " << dz << ", offset " << offset; + Joy_axis_dead_zone_size[Action::HEADING] = -1; + } + } +} + +TEST_F(JoyScalingTest, ActionsOutsideTheRangeUseTheGlobalSettings) +{ + for (int i = 0; i < Action::NUM_VALUES; ++i) { + Joy_axis_dead_zone_size[i] = 0; + Joy_axis_sensitivity[i] = 0; + } + const int raw = JOY_AXIS_CENTER + 5000; + ASSERT_EQ(joy_get_scaled_reading(raw, -1), 0); + ASSERT_EQ(joy_get_scaled_reading(raw, Action::NUM_VALUES), 0); + ASSERT_GT(joy_get_scaled_reading(raw, Action::NUM_VALUES - 1), 0); +} + +TEST_F(JoyScalingTest, BoundAxisUsesItsActionSettings) +{ + int axis_in[CID_JOY_MAX + 1][JOY_NUM_AXES] = {}; + int axis_out[Action::NUM_VALUES] = {}; + axis_in[CID_JOY1][JOY_X_AXIS] = JOY_AXIS_CENTER + 5000; + const CC_bind bind(CID_JOY1, JOY_X_AXIS); + + scale_invert(bind, Action::BANK, CC_TYPE_AXIS_REL, 0.016f, axis_in, axis_out); + ASSERT_EQ(axis_out[Action::BANK], 0); + + Joy_axis_dead_zone_size[Action::BANK] = 5; + scale_invert(bind, Action::BANK, CC_TYPE_AXIS_REL, 0.016f, axis_in, axis_out); + ASSERT_GT(axis_out[Action::BANK], 0); +} + +TEST_F(JoyScalingTest, OptionsBindToTheirOwnAction) +{ + const struct { + const char* key; + int* dest; + } options_to_check[] = { + {"Input.HeadingDeadZone", &Joy_axis_dead_zone_size[Action::HEADING]}, + {"Input.PitchDeadZone", &Joy_axis_dead_zone_size[Action::PITCH]}, + {"Input.BankDeadZone", &Joy_axis_dead_zone_size[Action::BANK]}, + {"Input.RelThrottleDeadZone", &Joy_axis_dead_zone_size[Action::REL_THROTTLE]}, + {"Input.HeadingSensitivity", &Joy_axis_sensitivity[Action::HEADING]}, + {"Input.PitchSensitivity", &Joy_axis_sensitivity[Action::PITCH]}, + {"Input.BankSensitivity", &Joy_axis_sensitivity[Action::BANK]}, + {"Input.RelThrottleSensitivity", &Joy_axis_sensitivity[Action::REL_THROTTLE]}, + }; + auto* mgr = options::OptionsManager::instance(); + for (const auto& entry : options_to_check) { + auto* opt = mgr->getOptionByKey(entry.key); + ASSERT_NE(opt, nullptr) << entry.key; + ASSERT_TRUE(opt->getFlags()[options::OptionFlags::RangeTypeInteger]) << entry.key; + + mgr->setOverride(entry.key, "7", "test"); + opt->loadInitial(); + for (const auto& other : options_to_check) { + ASSERT_EQ(*other.dest, other.dest == entry.dest ? 7 : -1) << entry.key << " set " << other.key; + } + mgr->clearOverrides(); + *entry.dest = -1; + } +} diff --git a/test/src/source_groups.cmake b/test/src/source_groups.cmake index 1c268dda86e..1ea89b6e821 100644 --- a/test/src/source_groups.cmake +++ b/test/src/source_groups.cmake @@ -17,6 +17,10 @@ add_file_folder("CFile" cfile/cfile.cpp ) +add_file_folder("ControlConfig" + controlconfig/test_joy_scaling.cpp +) + add_file_folder("Globalincs" globalincs/test_flagset.cpp globalincs/test_safe_strings.cpp From ed63366baf3dbe08a45dad216bb0e958d6fcbd66 Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Thu, 15 Oct 2026 07:09:49 -0700 Subject: [PATCH 2/2] Keep the per-axis joystick rows under the global ones Options sort by importance from highest to lowest, so the eight per-axis rows now sit below Sensitivity and Deadzone as one block, heading first. The -deadzone help now says a per-axis deadzone still applies to its axis. --- code/cmdline/cmdline.cpp | 2 +- code/controlconfig/controlsconfigcommon.cpp | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/code/cmdline/cmdline.cpp b/code/cmdline/cmdline.cpp index 1351c72f1db..da658a768e0 100644 --- a/code/cmdline/cmdline.cpp +++ b/code/cmdline/cmdline.cpp @@ -404,7 +404,7 @@ cmdline_parm stretch_menu("-stretch_menu", nullptr, AT_NONE); // Cmdline_stretch cmdline_parm capture_mouse("-capture_mouse", nullptr, AT_NONE); // Cmdline_capture_mouse cmdline_parm vr("-vr", nullptr, AT_NONE); cmdline_parm deadzone("-deadzone", -"Sets the joystick deadzone. Integer value from 0 to 100 as a percentage of the joystick's range (100% would make the stick do nothing). Disables deadzone slider in the in-game Options menu.", AT_INT); //Cmdline_deadzone +"Sets the joystick deadzone. Integer value from 0 to 100 as a percentage of the joystick's range (100% would make the stick do nothing). Disables deadzone slider in the in-game Options menu; a per-axis deadzone set there still applies to its axis.", AT_INT); //Cmdline_deadzone int Cmdline_autopilot_interruptable = 1; bool Cmdline_stretch_menu = false; diff --git a/code/controlconfig/controlsconfigcommon.cpp b/code/controlconfig/controlsconfigcommon.cpp index 3b77e5c3b6f..1635ad20ae0 100644 --- a/code/controlconfig/controlsconfigcommon.cpp +++ b/code/controlconfig/controlsconfigcommon.cpp @@ -54,7 +54,7 @@ auto DeadZoneOption = options::OptionBuilder("Input.JoystickDeadZone", .level(options::ExpertLevel::Beginner) .default_val(10) .bind_to(&Joy_dead_zone_size) - .importance(1) + .importance(11) .flags({options::OptionFlags::RetailBuiltinOption}) .finish(); @@ -69,7 +69,7 @@ auto SensitivityOption = options::OptionBuilder("Input.JoystickSensitivity" .level(options::ExpertLevel::Beginner) .default_val(9) .bind_to(&Joy_sensitivity) - .importance(2) + .importance(12) .flags({options::OptionFlags::RetailBuiltinOption}) .finish(); @@ -106,56 +106,56 @@ static auto HeadingDeadZoneOption __UNUSED = make_axis_option("Input.HeadingDead "The deadzone for the axis bound to heading; Use Global follows Deadzone", 45, &Joy_axis_dead_zone_size[Action::HEADING], - 3); + 10); // coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton static auto PitchDeadZoneOption __UNUSED = make_axis_option("Input.PitchDeadZone", "Pitch Deadzone", "The deadzone for the axis bound to pitch; Use Global follows Deadzone", 45, &Joy_axis_dead_zone_size[Action::PITCH], - 4); + 9); // coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton static auto BankDeadZoneOption __UNUSED = make_axis_option("Input.BankDeadZone", "Bank Deadzone", "The deadzone for the axis bound to bank; Use Global follows Deadzone", 45, &Joy_axis_dead_zone_size[Action::BANK], - 5); + 8); // coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton static auto RelThrottleDeadZoneOption __UNUSED = make_axis_option("Input.RelThrottleDeadZone", "Relative Throttle Deadzone", "The deadzone for the axis bound to relative throttle; Use Global follows Deadzone", 45, &Joy_axis_dead_zone_size[Action::REL_THROTTLE], - 6); + 7); // coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton static auto HeadingSensitivityOption __UNUSED = make_axis_option("Input.HeadingSensitivity", "Heading Sensitivity", "The sensitivity for the axis bound to heading; Use Global follows Sensitivity", 9, &Joy_axis_sensitivity[Action::HEADING], - 7); + 6); // coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton static auto PitchSensitivityOption __UNUSED = make_axis_option("Input.PitchSensitivity", "Pitch Sensitivity", "The sensitivity for the axis bound to pitch; Use Global follows Sensitivity", 9, &Joy_axis_sensitivity[Action::PITCH], - 8); + 5); // coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton static auto BankSensitivityOption __UNUSED = make_axis_option("Input.BankSensitivity", "Bank Sensitivity", "The sensitivity for the axis bound to bank; Use Global follows Sensitivity", 9, &Joy_axis_sensitivity[Action::BANK], - 9); + 4); // coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton static auto RelThrottleSensitivityOption __UNUSED = make_axis_option("Input.RelThrottleSensitivity", "Relative Throttle Sensitivity", "The sensitivity for the axis bound to relative throttle; Use Global follows Sensitivity", 9, &Joy_axis_sensitivity[Action::REL_THROTTLE], - 10); + 3); //! arrays which hold the key mappings. The array index represents a key-independent action. //! please use SPACES for aligning the fields of this array