diff --git a/CMakeLists.txt b/CMakeLists.txt index 8f711a3f..beb081cb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -210,6 +210,7 @@ set(XWA_RUNTIME_SOURCES src/xwa_runtime/camera/camera_motion.c src/xwa_runtime/camera/exterior_hud.c src/xwa_runtime/config/modern_controller_options_screen.c + src/xwa_runtime/config/modern_head_tracking_screen.c src/xwa_runtime/config/modern_input_options_screen.c src/xwa_runtime/config/modern_options_menu.c src/xwa_runtime/config/modern_pilot_profiles_screen.c @@ -288,6 +289,9 @@ set(XWA_RUNTIME_SOURCES src/xwa_runtime/models/craft_sounds.c src/xwa_runtime/models/opt_synth.c src/xwa_runtime/models/mission_art.c + src/xwa_runtime/input/head_tracking.c + src/xwa_runtime/input/opentrack_udp.c + src/xwa_runtime/input/trackir.c src/xwa_runtime/runtime/flight_pause_task.c src/xwa_runtime/runtime/flight_task.c src/xwa_runtime/runtime/frontend_task.c @@ -323,6 +327,11 @@ target_include_directories(xwa_input_options PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/ target_link_libraries(xwa_input_options PUBLIC aeron) add_library(xwa_core ${XWA_RECOVERED_SOURCES} ${XWA_RUNTIME_SOURCES}) +if(WIN32) + # Head tracking: advapi32 finds the installed TrackIR NPClient, which is + # loaded at runtime (no TrackIR SDK is bundled); ws2_32 receives OpenTrack UDP. + target_link_libraries(xwa_core PRIVATE advapi32 ws2_32) +endif() # The recovered sources reproduce MSVC 5.0-era code, which reinterprets one # struct pointer type as another (iMUSE passes ImStreamZone*/ImTrack* lists @@ -762,6 +771,7 @@ set(XWA_FORMAT_HEADERS src/xwa_runtime/config/modern_camera_shake_options.h src/xwa_runtime/config/modern_input_options.h src/xwa_runtime/config/modern_controller_options_screen.h + src/xwa_runtime/config/modern_head_tracking_screen.h src/xwa_runtime/config/modern_input_options_screen.h src/xwa_runtime/config/modern_options_menu.h src/xwa_runtime/config/modern_pilot_profiles_screen.h @@ -873,6 +883,9 @@ set(XWA_FORMAT_HEADERS src/xwa_runtime/input/winmm_joystick_provider.h src/xwa_runtime/input/controller_mapping.h src/xwa_runtime/input/mouse_flight.h + src/xwa_runtime/input/head_tracking.h + src/xwa_runtime/input/opentrack_udp.h + src/xwa_runtime/input/trackir.h src/xwa_runtime/timing/modern_flight_timing.h src/xwa_runtime/runtime/movie_task.h src/xwa_runtime/hooks/orientation_hook.h diff --git a/resources/remaster/config.yaml b/resources/remaster/config.yaml index f22dcbf4..1a16d812 100644 --- a/resources/remaster/config.yaml +++ b/resources/remaster/config.yaml @@ -6,6 +6,19 @@ input: mouse_mode: 'position' mouse_sensitivity: 5 mouse_invert_y: true + # Native TrackIR is Windows-only; OpenTrack UDP works on all platforms. + # Configure OpenTrack output to UDP over network -> 127.0.0.1:4242. + # Inversion is relative to OpenXWA's standard head-motion directions. + head_tracking: + enabled: false + source: 'trackir' + invert: + yaw: false + pitch: false + roll: false + x: false + y: false + z: false controller: device: # Empty GUID selects the first connected controller. @@ -21,6 +34,7 @@ input: pitch: { source: 'lefty', invert: true, deadzone: 0.08 } throttle: { source: 'righttrigger', invert: false, deadzone: 0.0 } roll: { source: 'rightx', invert: false, deadzone: 0.08 } + hyperdrive: { source: 'none', invert: false, deadzone: 0.0 } buttons: '1': 'south' '2': 'east' @@ -67,6 +81,7 @@ input: pitch: { source: 1, invert: true, deadzone: 0.08 } throttle: { source: 2, invert: false, deadzone: 0.08 } roll: { source: 3, invert: false, deadzone: 0.08 } + hyperdrive: { source: 'none', invert: false, deadzone: 0.0 } buttons: '1': 0 '2': 1 diff --git a/src/xwa/config/game_config.c b/src/xwa/config/game_config.c index 6849883a..8503eb8d 100644 --- a/src/xwa/config/game_config.c +++ b/src/xwa/config/game_config.c @@ -39,6 +39,7 @@ #ifdef XWA_MODERN #include "xwa_runtime/config/modern_controller_options_screen.h" +#include "xwa_runtime/config/modern_head_tracking_screen.h" #include "xwa_runtime/config/modern_input_options.h" #include "xwa_runtime/config/modern_input_options_screen.h" #include "xwa_runtime/config/modern_mods.h" @@ -952,9 +953,19 @@ int Config_ControllerOptionsScreen(void) { g_menuCursorRow = 4; break; case 2: + XwaModernControllerOptionsScreen_SelectSlot(0); g_pendingMenuScreen = 20; g_menuCursorRow = 0; break; + case 4: + XwaModernControllerOptionsScreen_SelectSlot(1); + g_pendingMenuScreen = 20; + g_menuCursorRow = 0; + break; + case 3: + g_pendingMenuScreen = 24; + g_menuCursorRow = 0; + break; default: break; } @@ -2625,6 +2636,13 @@ int Config_OptionsDatapadUpdate(int frameState) { } done = 0; break; + case 24: + if (XwaModernHeadTrackingScreen_Update(g_configMenuCenterX, &g_menuCursorRow)) { + g_pendingMenuScreen = 4; + g_menuCursorRow = 5; + } + done = 0; + break; #endif default: done = 0; @@ -5364,12 +5382,14 @@ void Config_ApplyModernInputOptions(const XwaModernInputOptions* options) { strcmp(previousDevice.guid, options->controller.device.guid) != 0 || strcmp(previousDevice.path, options->controller.device.path) != 0 || previousDevice.ordinal != options->controller.device.ordinal); - g_gameConfig.rudderEnabled = (uint8_t)options->controller.roll_enabled; + g_gameConfig.rudderEnabled = (uint8_t)((options->controller.enabled && options->controller.roll_enabled) || + (options->controller2.enabled && options->controller2.roll_enabled)); /* The axes are inverted where the Aeron input becomes WinMM joystick values, so the game's own flip settings stay off. */ g_gameConfig.flipRudder = 0; g_gameConfig.flipY = 0; - g_gameConfig.ffEnabled = (uint8_t)options->controller.rumble_enabled; + g_gameConfig.ffEnabled = (uint8_t)((options->controller.enabled && options->controller.rumble_enabled) || + (options->controller2.enabled && options->controller2.rumble_enabled)); g_gameConfig.ffStrength = (uint8_t)options->controller.rumble_strength; g_gameConfig.ffCenter = 0; XwaControllerMapping_CopySelectedActions(g_gameConfig.joyButtons); diff --git a/src/xwa/flight/flight.c b/src/xwa/flight/flight.c index bca949e3..c1015c11 100644 --- a/src/xwa/flight/flight.c +++ b/src/xwa/flight/flight.c @@ -60,6 +60,9 @@ #include "xwa_runtime/models/craft_hyperspace.h" #include "xwa_runtime/models/model_roles.h" #ifdef XWA_MODERN +#include "xwa_runtime/input/controller_mapping.h" +#endif +#ifdef XWA_MODERN #include "xwa_runtime/input/mouse_flight.h" #include "xwa_runtime/models/articulation.h" #include "xwa_runtime/models/craft_sounds.h" @@ -2643,6 +2646,32 @@ uint16_t FlightInput_Read(int playerIdxOrSentinel) { } g_controlMask = filteredMask; keyMods = keyModAlt2 + 2 * keyModAlt3; +#ifdef XWA_MODERN + /* Two independent physical devices, including their expanded button + * bindings, feed the same source-agnostic flight key stream. */ + XwaControllerMapping_ReadActions(&key, &keyMods); + + /* The lever is a Space alternative exclusively for hyperspace + * confirmation action 10; action 2 enters a hangar and must not + * respond. Consume early strokes so they cannot trigger later. */ + { + const int lever_pushed = XwaControllerMapping_ConsumeHyperdriveEngage(); + if (lever_pushed && g_joystickEnabled && key == 0 && !g_filmPlaybackMode && + !g_inHangarReady && g_localPlayer >= 0 && g_localPlayer < 8 && + g_players[g_localPlayer].pendingActionId == 10 && + g_players[g_localPlayer].msgTypeId == 0 && + !g_players[g_localPlayer].mapCameraState && + !g_players[g_localPlayer].hasCheckpointFlag && + g_players[g_localPlayer].hyperspacePhase == PLAYER_HYPERSPACE_PHASE_NONE && + (uint16_t)g_players[g_localPlayer].objectIndex != 0xffffu) { + const MobileObject* ship = + g_objectTable[(uint16_t)g_players[g_localPlayer].objectIndex].mobj; + if (ship && ship->pCraft && (ship->pCraft->systemFlags & 0x80u)) { + key = KEY_SPACE; + } + } + } +#endif } if (key == 0) { diff --git a/src/xwa/flight/flight_view.c b/src/xwa/flight/flight_view.c index 9fab1f04..682de1f5 100644 --- a/src/xwa/flight/flight_view.c +++ b/src/xwa/flight/flight_view.c @@ -14,10 +14,12 @@ #include "xwa/flight/yard.h" #include "xwa/util/time.h" #ifdef XWA_MODERN +#include "aeron/asset/opt_model.h" #include "aeron/log.h" #include "xwa/flight/flight_debug.h" #include "xwa_runtime/camera/camera_motion.h" #include "xwa_runtime/hooks/orientation_hook.h" +#include "xwa_runtime/input/head_tracking.h" #include "xwa_runtime/models/gunner_seats.h" #include "xwa_runtime/runtime/port.h" #include "xwa_runtime/snapshot/snapshot_camera.h" @@ -285,6 +287,113 @@ static void FVIEW_StoreCamShadow(const double m[9]) { g_camShadowValid = 1; FVIEW_CopyShadowToViewMtx(); } +/* Head roll turns the observer about the VIEW direction, not the ship. + * Keep the original Q15 camera and its smooth double-precision shadow in + * agreement: the classic culler and remastered scene must see the same view. */ +static int FVIEW_HeadTrackingToQ15(double value) { + long scaled = lround(value * 32768.0); + if (scaled < -32767) + return -32767; + if (scaled > 32767) + return 32767; + return (int)scaled; +} + +/* Writes the head-tracked rows to the Q15 camera and its double shadow. Roll + leaves the forward row as it is. */ +static void FVIEW_StoreHeadTrackedCamera(const double rows[9], int forwardChanged) { + g_camMatR0_X = g_curMatR0_X = FVIEW_HeadTrackingToQ15(rows[0]); + g_camMatR0_Y = g_curMatR0_Y = FVIEW_HeadTrackingToQ15(rows[1]); + g_camMatR0_Z = g_curMatR0_Z = FVIEW_HeadTrackingToQ15(rows[2]); + g_camMatR1_X = g_curMatR1_X = FVIEW_HeadTrackingToQ15(rows[3]); + g_camMatR1_Y = g_curMatR1_Y = FVIEW_HeadTrackingToQ15(rows[4]); + g_camMatR1_Z = g_curMatR1_Z = FVIEW_HeadTrackingToQ15(rows[5]); + if (forwardChanged) { + g_camMatR2_X = g_curMatR2_X = FVIEW_HeadTrackingToQ15(rows[6]); + g_camMatR2_Y = g_curMatR2_Y = FVIEW_HeadTrackingToQ15(rows[7]); + g_camMatR2_Z = g_curMatR2_Z = FVIEW_HeadTrackingToQ15(rows[8]); + } + FVIEW_StoreCamShadow(rows); +} + +static void FVIEW_ApplyHeadRoll(int16_t rollQ16) { + float original[9]; + double rows[9]; + double angle; + double co; + double si; + int i; + if (!rollQ16) + return; + FVIEW_CopyRenderCameraRows(original); + for (i = 0; i < 9; ++i) + rows[i] = (double)original[i]; + angle = ((double)rollQ16 * 3.14159265358979323846) / 32768.0; + co = cos(angle); + si = sin(angle); + for (i = 0; i < 3; ++i) { + rows[i] = co * (double)original[i] + si * (double)original[i + 3]; + rows[i + 3] = -si * (double)original[i] + co * (double)original[i + 3]; + } + FVIEW_StoreHeadTrackedCamera(rows, 0); +} + +/* Apply look to an already positioned camera. External cameras establish + * their own orbit and collision clearance, while hyperspace forces a special + * orientation after the normal cockpit calculation. Head motion must be + * overlaid AFTER both camera modes establish their final base view. */ +static void FVIEW_ApplyHeadLookToCamera(const XwaHeadPose* head) { + float source[9]; + double rows[9]; + double up[3]; + int i; + if (!head) + return; + FVIEW_CopyRenderCameraRows(source); + for (i = 0; i < 9; ++i) + rows[i] = (double)source[i]; + up[0] = rows[3]; + up[1] = rows[4]; + up[2] = rows[5]; + /* This is the exact pitch-then-yaw order used by + * FVIEW_BuildCameraOrient's ordinary cockpit look offsets. */ + FVIEW_ShadowRotateAxes(rows, rows[0], rows[1], rows[2], head->pitch_q16); + FVIEW_ShadowRotateAxes(rows, up[0], up[1], up[2], head->yaw_q16); + FVIEW_StoreHeadTrackedCamera(rows, 1); + FVIEW_ApplyHeadRoll(head->roll_q16); +} + +/* Transient visual translation. The original culler accepts only whole OPT + * coordinates, but the render snapshot retains the original float offset + * to reconstruct fractional movement. The basis is the view BEFORE + * head rotation, so leaning is independent of where the user looks. */ +static void FVIEW_ApplyHeadTranslation(int playerIdx, const XwaHeadPose* head, const float baseRows[9]) { + const float scale = AERON_OPT_UNITS_PER_METER * 0.01f; + const float local[3] = { -head->left_cm, head->up_cm, -head->back_cm }; + float worldOffset[3]; + for (int axis = 0; axis < 3; ++axis) { + worldOffset[axis] = + (local[0] * baseRows[axis] + local[1] * baseRows[3 + axis] + local[2] * baseRows[6 + axis]) * + scale; + } + /* viewState is packed, so add to its members directly instead of through pointers. */ + g_players[playerIdx].viewState.savedTargetX += (int)lroundf(worldOffset[0]); + g_players[playerIdx].viewState.savedTargetY += (int)lroundf(worldOffset[1]); + g_players[playerIdx].viewState.savedTargetZ += (int)lroundf(worldOffset[2]); + XwaHeadTracking_SetCameraOffset(worldOffset); +} + +/* Overlay local head movement on a camera that already established its base pose. */ +void FlightView_ApplyHeadTrackingToCurrentCamera(int playerIdx) { + XwaHeadPose head; + float baseRows[9]; + if (playerIdx != g_localPlayer || !XwaHeadTracking_Poll(&head)) + return; + FVIEW_CopyRenderCameraRows(baseRows); + FVIEW_ApplyHeadLookToCamera(&head); + FVIEW_ApplyHeadTranslation(playerIdx, &head, baseRows); +} + #endif // FUNCTION: XWA 0x497610 @@ -913,6 +1022,9 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { XwaSnapshotCamera_BeginUpdate(playerIdx); XwaCameraMotion_Update(playerIdx); + /* Per-frame local head pose only. Other players, replay and maps remain unchanged. */ + if (playerIdx == g_localPlayer) + XwaHeadTracking_ClearPose(); #endif if (g_players[playerIdx].mapCameraState) { FlightMap_UpdateCamera(playerIdx); @@ -1147,6 +1259,23 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { } } } else { +#ifdef XWA_MODERN + XwaHeadPose head = { 0 }; + float seatRows[9] = { 0 }; + int tracking = 0; + /* Track the local user's normal cockpit view (pilot OR gun turret). + * The camera builder applies head look AFTER turret aim/mount rotations, + * keeping gun aiming independent. Skip external/cinematic views. + * This also runs during hyperspace: the HD renderer keeps this + * cockpit camera through the jump (XwaSnapshotCamera_NoteHyperspaceOverride). */ + if (playerIdx == g_localPlayer && !g_filmRecording && !g_filmPlaybackMode && + g_players[playerIdx].cockpitVisible && + ((g_players[playerIdx].currentSeatIdx == 0 && g_players[playerIdx].cockpitLookAvailable) || + (g_players[playerIdx].currentSeatIdx > 0 && g_players[playerIdx].cockpitToggleAvailable)) && + cameraFocusObjIdx == (unsigned int)g_players[playerIdx].objectIndex) { + tracking = XwaHeadTracking_Poll(&head); + } +#endif g_players[playerIdx].viewState.viewRoll = g_objectTable[cameraFocusObjIdx].roll; g_players[playerIdx].viewState.viewPitch = g_objectTable[g_players[playerIdx].viewState.cameraFocusObjIdx].pitch; @@ -1154,6 +1283,18 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { g_objectTable[g_players[playerIdx].viewState.cameraFocusObjIdx].yaw; g_players[playerIdx].viewState.hudAimY = (uint16_t)g_players[playerIdx].lookYawOffset; g_players[playerIdx].viewState.hudAimX = (uint16_t)g_players[playerIdx].lookPitchOffset; +#ifdef XWA_MODERN + if (tracking) { + /* Use the zero-look seat frame to translate the head; leaning must + * not change direction when the pilot turns their head. */ + FVIEW_BuildCameraOrient(g_players[playerIdx].viewState.viewRoll, + (int16_t)g_players[playerIdx].viewState.viewPitch, + (int16_t)g_players[playerIdx].viewState.viewYaw, + g_players[playerIdx].viewState.viewAngleD, 0, 0, + &g_objectTable[cameraFocusObjIdx], playerIdx); + FVIEW_CopyRenderCameraRows(seatRows); + } +#endif g_players[playerIdx].viewState.viewYaw = (uint16_t)(g_players[playerIdx].viewState.viewYaw + g_players[playerIdx].viewState.cameraYawDelta); @@ -1166,9 +1307,18 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { FVIEW_BuildCameraOrient( g_players[playerIdx].viewState.viewRoll, (int16_t)g_players[playerIdx].viewState.viewPitch, (int16_t)g_players[playerIdx].viewState.viewYaw, g_players[playerIdx].viewState.viewAngleD, +#ifdef XWA_MODERN + (int16_t)(g_players[playerIdx].viewState.hudAimX + (tracking ? head.pitch_q16 : 0)), + (int16_t)(g_players[playerIdx].viewState.hudAimY + (tracking ? head.yaw_q16 : 0)), +#else (int16_t)g_players[playerIdx].viewState.hudAimX, (int16_t)g_players[playerIdx].viewState.hudAimY, +#endif &g_objectTable[g_players[playerIdx].viewState.cameraFocusObjIdx], playerIdx); +#ifdef XWA_MODERN + if (tracking) + FVIEW_ApplyHeadRoll(head.roll_q16); +#endif g_players[playerIdx].viewState.savedTargetX = g_objectTable[g_players[playerIdx].viewState.cameraFocusObjIdx].world_x; g_players[playerIdx].viewState.savedTargetY = @@ -1187,6 +1337,11 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { g_players[playerIdx].viewState.cameraPanDeltaY >> 4; g_players[playerIdx].viewState.savedTargetZ += g_players[playerIdx].viewState.cameraPanDeltaZ >> 4; +#ifdef XWA_MODERN + if (tracking) { + FVIEW_ApplyHeadTranslation(playerIdx, &head, seatRows); + } +#endif } } @@ -1218,6 +1373,26 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { g_players[playerIdx].viewState.savedTargetY += 16 * phaseTicks; } } +#ifdef XWA_MODERN + /* Both paths bypass or replace the usual cockpit camera: + * - External view computes orbit, distance and collision clearance first. + * - Hyperspace rebuilds the camera from its transition pose. + * Apply 6DOF only after these modes have settled their base camera. + * Never write the headset pose into the ship's control/aim state. */ + if (playerIdx == g_localPlayer && !g_players[playerIdx].mapCameraState && !g_filmRecording && + !g_filmPlaybackMode) { + if (g_players[playerIdx].hyperspacePhase != PLAYER_HYPERSPACE_PHASE_NONE) { + /* The cockpit camera above already leaned the position, which the + * hyperspace camera kept; only turn the classic view here. */ + XwaHeadPose head; + if (XwaHeadTracking_Poll(&head)) + FVIEW_ApplyHeadLookToCamera(&head); + } else if (g_players[playerIdx].viewState.externalCameraActive && + !g_players[playerIdx].viewState.playerInputBlocked) { + FlightView_ApplyHeadTrackingToCurrentCamera(playerIdx); + } + } +#endif } // FUNCTION: XWA 0x4F1B00 diff --git a/src/xwa/flight/hangar.c b/src/xwa/flight/hangar.c index dda97e6a..cfaa3014 100644 --- a/src/xwa/flight/hangar.c +++ b/src/xwa/flight/hangar.c @@ -45,6 +45,7 @@ #include "xwa/math/scalar.h" #include "xwa/math/trig2.h" #ifdef XWA_MODERN +#include "xwa_runtime/input/head_tracking.h" #include "xwa_runtime/models/articulation.h" #include "xwa_runtime/models/craft_definitions.h" #include "xwa_runtime/models/craft_sounds.h" @@ -323,6 +324,8 @@ int g_hangarInitialReadyEntryPending; // GLOBAL: XWA 0x9C6FB4 int g_hangarHullDamageWarningPlayed; #ifdef XWA_MODERN +/* Untracked anchor of hangar shot cameras; head translation must not accumulate. */ +static int s_hangarCameraBase[3]; static GameConfig g_hangarOptionsModalOldConfig; static int g_hangarOptionsModalPending; #endif @@ -2852,7 +2855,25 @@ void Hangar_RenderReadyScreen(void) { Mission_SetActiveRegionObjectRanges(player->regionIndex); if (g_hangarAutoCam) { +#ifdef XWA_MODERN + XwaHeadTracking_ClearPose(); + if (!g_filmPlaybackMode) { + player->viewState.savedTargetX = s_hangarCameraBase[0]; + player->viewState.savedTargetY = s_hangarCameraBase[1]; + player->viewState.savedTargetZ = s_hangarCameraBase[2]; + } +#endif Hangar_UpdateReadyAutoCamera(player); +#ifdef XWA_MODERN + if (!g_filmPlaybackMode) { + s_hangarCameraBase[0] = player->viewState.savedTargetX; + s_hangarCameraBase[1] = player->viewState.savedTargetY; + s_hangarCameraBase[2] = player->viewState.savedTargetZ; + if (!g_filmRecording) { + FlightView_ApplyHeadTrackingToCurrentCamera(g_localPlayer); + } + } +#endif } else { FlightView_UpdatePlayerCamera(g_localPlayer); } @@ -4987,6 +5008,11 @@ void Hangar_SetCameraShot(int shotIndex) { g_players[g_localPlayer].viewState.savedTargetX = worldX; g_players[g_localPlayer].viewState.savedTargetY = worldY; g_players[g_localPlayer].viewState.savedTargetZ = worldZ; +#ifdef XWA_MODERN + s_hangarCameraBase[0] = worldX; + s_hangarCameraBase[1] = worldY; + s_hangarCameraBase[2] = worldZ; +#endif g_players[g_localPlayer].viewState.viewRoll = 0; g_players[g_localPlayer].viewState.viewPitch = 0x4000; g_players[g_localPlayer].viewState.viewYaw = 0; diff --git a/src/xwa/flight/hud/hud.c b/src/xwa/flight/hud/hud.c index bf961003..d5ee7f03 100644 --- a/src/xwa/flight/hud/hud.c +++ b/src/xwa/flight/hud/hud.c @@ -36,6 +36,7 @@ #include "xwa/math/trig2.h" #ifdef XWA_MODERN #include "xwa_runtime/camera/exterior_hud.h" +#include "xwa_runtime/input/head_tracking.h" #include "xwa_runtime/models/component_strength.h" #include "xwa_runtime/models/mission_settings.h" #include "xwa_runtime/models/object_limits.h" @@ -3446,6 +3447,17 @@ void Hud_SetupReticle(void) { centerY = g_reticleCenterY; deltaX = (float)(screenX - centerX); deltaY = (float)(screenY - centerY); +#ifdef XWA_MODERN + /* Weapon indicators retain their ship-relative layout during head tracking. */ + if (XwaHeadTracking_CurrentPose(NULL)) { + const ModelWeaponHardpoint* point = + &g_modelDefs[craft->modelDefIndex].weaponHardpoints[reticleSlot]; + if (point->x || point->z) { + deltaX = (float)point->x; + deltaY = -(float)point->z; + } + } +#endif g_reticleLaserAimPoints[reticleSlot].x = centerX + (int)(g_reticleAimPointDistanceBias * @@ -6920,6 +6932,16 @@ void Hud_DrawReticle3D(void) { uint8_t inRange; uint16_t lockRange; unsigned int lockRangeDivisor; +#ifdef XWA_MODERN + /* Head look moves the view like the cockpit look offsets do, so the + reticle follows the nose in the same way. */ + XwaHeadPose head; + const int headLook = XwaHeadTracking_CurrentPose(&head) && (head.yaw_q16 || head.pitch_q16); + const int16_t lookYaw = (int16_t)(g_players[g_localPlayer].lookYawOffset + (headLook ? head.yaw_q16 : 0)); + const int16_t lookPitch = + (int16_t)(g_players[g_localPlayer].lookPitchOffset + (headLook ? head.pitch_q16 : 0)); + const int projectReticle = headLook || HUD_RETICLE_PROJECTED(); +#endif g_reticleDrawX = g_reticleCenterX; g_reticleDrawY = g_reticleCenterY; @@ -6962,22 +6984,40 @@ void Hud_DrawReticle3D(void) { #endif lockRangeDivisor = lockRange / 200; +#ifdef XWA_MODERN + if (Hud_AbsLookDegreesFromOffset(lookYaw) >= 45 || Hud_AbsLookDegreesFromOffset(lookPitch) >= 45) { + return; + } + + if (projectReticle) { +#else if (Hud_AbsLookDegreesFromOffset(g_players[g_localPlayer].lookYawOffset) >= 45 || Hud_AbsLookDegreesFromOffset(g_players[g_localPlayer].lookPitchOffset) >= 45) { return; } if (HUD_RETICLE_PROJECTED()) { +#endif int worldZ; - pai_RotateLocalVectorToWorldScratch(&g_objectTable[g_players[g_localPlayer].objectIndex], 0, 0, - 1000000); - g_camRelWorldX = g_objectTable[g_players[g_localPlayer].objectIndex].world_x + g_rotatedX; - g_camRelWorldY = g_objectTable[g_players[g_localPlayer].objectIndex].world_y + g_rotatedY; - worldZ = g_objectTable[g_players[g_localPlayer].objectIndex].world_z + g_rotatedZ; - g_camRelWorldX -= g_players[g_localPlayer].viewState.savedTargetX; - g_camRelWorldY -= g_players[g_localPlayer].viewState.savedTargetY; - g_camRelWorldZ = worldZ - g_players[g_localPlayer].viewState.savedTargetZ; +#ifdef XWA_MODERN + if (headLook && g_players[g_localPlayer].currentSeatIdx > 0) { + /* The gun's existing Q15 firing vector is independent of head look. */ + g_camRelWorldX = Xwa_Q15Mul(1000000, g_players[g_localPlayer].turretCamMat[0]); + g_camRelWorldY = Xwa_Q15Mul(1000000, g_players[g_localPlayer].turretCamMat[1]); + g_camRelWorldZ = Xwa_Q15Mul(1000000, g_players[g_localPlayer].turretCamMat[2]); + } else +#endif + { + pai_RotateLocalVectorToWorldScratch(&g_objectTable[g_players[g_localPlayer].objectIndex], 0, 0, + 1000000); + g_camRelWorldX = g_objectTable[g_players[g_localPlayer].objectIndex].world_x + g_rotatedX; + g_camRelWorldY = g_objectTable[g_players[g_localPlayer].objectIndex].world_y + g_rotatedY; + worldZ = g_objectTable[g_players[g_localPlayer].objectIndex].world_z + g_rotatedZ; + g_camRelWorldX -= g_players[g_localPlayer].viewState.savedTargetX; + g_camRelWorldY -= g_players[g_localPlayer].viewState.savedTargetY; + g_camRelWorldZ = worldZ - g_players[g_localPlayer].viewState.savedTargetZ; + } viewX = TRANSFM2_CamMatDotRow0(g_camRelWorldX, g_camRelWorldY, g_camRelWorldZ); viewY = TRANSFM2_CamMatDotRow1(g_camRelWorldX, g_camRelWorldY, g_camRelWorldZ); viewZ = TRANSFM2_CamMatDotRow2(g_camRelWorldX, g_camRelWorldY, g_camRelWorldZ); @@ -7028,7 +7068,11 @@ void Hud_DrawReticle3D(void) { } } +#ifdef XWA_MODERN + if (projectReticle) { +#else if (HUD_RETICLE_PROJECTED()) { +#endif int aimX; int drawX; @@ -7110,7 +7154,11 @@ void Hud_DrawReticle3D(void) { quad.screenX = g_reticleCenterX; quad.screenY = g_screenHeight - g_reticleCenterY; +#ifdef XWA_MODERN + if (projectReticle) { +#else if (HUD_RETICLE_PROJECTED()) { +#endif quad.screenX = g_reticleDrawX; quad.screenY = g_screenHeight - g_reticleDrawY; } diff --git a/src/xwa/input/dinput.c b/src/xwa/input/dinput.c index e890a724..c4521506 100644 --- a/src/xwa/input/dinput.c +++ b/src/xwa/input/dinput.c @@ -487,6 +487,11 @@ int DInput_DrainKeyboardEvents(void) { uint32_t flushCount; int result; + /* Quitting the game with an in-flight menu open releases the menu's + * surfaces after DInput_Shutdown, which has already freed the keyboard. */ + if (!g_dinputKeyboardDevice) { + return 0; + } /* Flush the buffered keyboard events (INFINITE), then refresh the immediate state * to recompute the cached Ctrl/Shift/Alt flags. */ flushCount = 0xFFFFFFFFu; diff --git a/src/xwa/render/render_scene_core.c b/src/xwa/render/render_scene_core.c index ea51aecd..863d037a 100644 --- a/src/xwa/render/render_scene_core.c +++ b/src/xwa/render/render_scene_core.c @@ -6,10 +6,12 @@ #ifdef XWA_MODERN #include "xwa/frontend/frontend_display.h" /* g_flightRenderToFrontend */ +#include "xwa_runtime/input/head_tracking.h" #include "xwa_runtime/models/articulation.h" #include "xwa_runtime/models/gunner_seats.h" #include "xwa_runtime/models/opt_synth.h" #include "xwa_runtime/models/weapon_models.h" +#include #endif #ifndef XWA_MODERN @@ -38,6 +40,28 @@ static int VirtualProtect(void* lpAddress, unsigned int dwSize, unsigned int flN } #endif +#ifdef XWA_MODERN +/* Classic model paths override the cockpit model's world-relative position + * with its untracked seat hardpoint, losing the head-tracked eye displacement. World + * geometry is already shifted by the integer camera offset; the small + * residual ensures fluid motion in classic F5 mode without filtering. */ +static void RenderScene_ApplyHeadTranslation(Vec3f* pos, int cockpit_active) { + float offset[3]; + if (!pos) + return; + XwaHeadTracking_GetCameraOffset(offset); + if (cockpit_active) { + pos->x -= offset[0]; + pos->y -= offset[1]; + pos->z -= offset[2]; + } else { + pos->x -= offset[0] - roundf(offset[0]); + pos->y -= offset[1] - roundf(offset[1]); + pos->z -= offset[2] - roundf(offset[2]); + } +} +#endif + // FUNCTION: XWA 0x4421C0 void RenderScene_SetDepthProjectionScale(float depthProjScale) { g_depthProjScale = depthProjScale; } @@ -429,6 +453,10 @@ int16_t RenderScene_DrawObjectModel(ObjectRecord* obj) { mesh.viewPos.z = -g_players[g_localPlayer].seatEyeWorldOffsetZ - g_players[g_localPlayer].viewState.cameraPanDeltaZ * g_cockpitPanPositionScale; } +#ifdef XWA_MODERN + /* Adjust the eye relative to the cockpit without moving the craft. */ + RenderScene_ApplyHeadTranslation(&mesh.viewPos, g_cockpitViewActive); +#endif mesh.viewOrient[0] = g_viewMtx00; mesh.viewOrient[1] = g_viewMtx10; @@ -831,6 +859,10 @@ int RenderScene_DrawObjectModelHardware(ObjectRecord* obj) { mesh.viewPos.z = -g_players[g_localPlayer].seatEyeWorldOffsetZ - g_players[g_localPlayer].viewState.cameraPanDeltaZ * g_cockpitPanPositionScale; } +#ifdef XWA_MODERN + /* Adjust the eye relative to the cockpit without moving the craft. */ + RenderScene_ApplyHeadTranslation(&mesh.viewPos, g_cockpitViewActive); +#endif viewMatrix.m[0] = g_viewMtx00; viewMatrix.m[1] = g_viewMtx10; @@ -1078,6 +1110,10 @@ void RenderScene_DrawNoAssetSourceModel(ObjectRecord* obj, int nodeSwitchIndex) mesh.viewPos.z = -g_players[g_localPlayer].seatEyeWorldOffsetZ - (double)g_players[g_localPlayer].viewState.cameraPanDeltaZ * 0.0625; } +#ifdef XWA_MODERN + /* Adjust the eye relative to the cockpit without moving the craft. */ + RenderScene_ApplyHeadTranslation(&mesh.viewPos, g_cockpitViewActive); +#endif mesh.viewOrient[0] = g_viewMtx00; mesh.viewOrient[1] = g_viewMtx10; diff --git a/src/xwa/render/renderer.h b/src/xwa/render/renderer.h index 8c6003a8..da944da3 100644 --- a/src/xwa/render/renderer.h +++ b/src/xwa/render/renderer.h @@ -1092,6 +1092,7 @@ void FVIEW_BuildCameraOrientNoTurret(int16_t rollQ16, int16_t pitchQ16, int16_t int FVIEW_ComputeObjectViewMatrix(void); #ifdef XWA_MODERN void FVIEW_CopyRenderCameraRows(float rows[9]); +void FlightView_ApplyHeadTrackingToCurrentCamera(int playerIdx); void FVIEW_SaveRenderCameraStateForViewport(void); void FVIEW_RestoreRenderCameraStateForViewport(void); #endif diff --git a/src/xwa_app/host_config.c b/src/xwa_app/host_config.c index fbddb7e8..60b51ea5 100644 --- a/src/xwa_app/host_config.c +++ b/src/xwa_app/host_config.c @@ -623,6 +623,18 @@ static int host_config_video_options(const AeronConfigFile* config, XwaModernVid } } } + /* Optional user preference; absent means the original pixelated look. */ + { + const char* key = "video.smooth_menu_fonts"; + const AeronConfigNode* node = AeronConfigFile_GetNode(config, key); + if (node) { + if (AeronConfigNode_Type(node) != AERON_CONFIG_BOOL) { + return host_config_error(error, error_size, "invalid video setting '%s'", key); + } + out->smooth_menu_fonts = AeronConfigNode_Bool(node, 0); + *override_mask |= XWA_MODERN_VIDEO_OVERRIDE_SMOOTH_MENU_FONTS; + } + } return 1; } @@ -837,39 +849,41 @@ static int host_config_raw_source(const AeronConfigFile* config, const char* key } static int host_config_controller_axes(const AeronConfigFile* config, int required, + const char* base, XwaControllerOptions* controller, char* error, size_t error_size) { - static const char* const axis_names[XWA_CONTROLLER_LOGICAL_AXIS_COUNT] = { "yaw", "pitch", "throttle", - "roll" }; + static const char* const axis_names[XWA_CONTROLLER_LOGICAL_AXIS_COUNT] = { + "yaw", "pitch", "throttle", "roll", "hyperdrive" + }; char key[128]; int i; for (i = 0; i < XWA_CONTROLLER_LOGICAL_AXIS_COUNT; ++i) { - snprintf(key, sizeof(key), "input.controller.gamepad.axes.%s.source", axis_names[i]); + snprintf(key, sizeof(key), "%s.gamepad.axes.%s.source", base, axis_names[i]); if (!host_config_gamepad_axis_source(config, key, required, &controller->gamepad.axes[i].source, error, error_size)) { return 0; } - snprintf(key, sizeof(key), "input.controller.gamepad.axes.%s.invert", axis_names[i]); + snprintf(key, sizeof(key), "%s.gamepad.axes.%s.invert", base, axis_names[i]); if (!host_config_input_bool(config, key, required, &controller->gamepad.axes[i].invert, error, error_size)) { return 0; } - snprintf(key, sizeof(key), "input.controller.gamepad.axes.%s.deadzone", axis_names[i]); + snprintf(key, sizeof(key), "%s.gamepad.axes.%s.deadzone", base, axis_names[i]); if (!host_config_input_float(config, key, required, 0.0, 1.0, &controller->gamepad.axes[i].deadzone, error, error_size)) { return 0; } - snprintf(key, sizeof(key), "input.controller.joystick.axes.%s.source", axis_names[i]); + snprintf(key, sizeof(key), "%s.joystick.axes.%s.source", base, axis_names[i]); if (!host_config_raw_source(config, key, required, AERON_CONTROLLER_AXIS_MAX, &controller->joystick.axes[i].source, error, error_size)) { return 0; } - snprintf(key, sizeof(key), "input.controller.joystick.axes.%s.invert", axis_names[i]); + snprintf(key, sizeof(key), "%s.joystick.axes.%s.invert", base, axis_names[i]); if (!host_config_input_bool(config, key, required, &controller->joystick.axes[i].invert, error, error_size)) { return 0; } - snprintf(key, sizeof(key), "input.controller.joystick.axes.%s.deadzone", axis_names[i]); + snprintf(key, sizeof(key), "%s.joystick.axes.%s.deadzone", base, axis_names[i]); if (!host_config_input_float(config, key, required, 0.0, 1.0, &controller->joystick.axes[i].deadzone, error, error_size)) { return 0; @@ -879,18 +893,19 @@ static int host_config_controller_axes(const AeronConfigFile* config, int requir } static int host_config_controller_buttons(const AeronConfigFile* config, int required, + const char* base, XwaControllerOptions* controller, char* error, size_t error_size) { char key[128]; int i; for (i = 0; i < XWA_CONTROLLER_LOGICAL_BUTTON_COUNT; ++i) { - snprintf(key, sizeof(key), "input.controller.gamepad.buttons.%d", i + 1); - if (!host_config_controller_digital_binding(config, key, required, 1, &controller->gamepad.buttons[i], + snprintf(key, sizeof(key), "%s.gamepad.buttons.%d", base, i + 1); + if (!host_config_controller_digital_binding(config, key, required && i < 16, 1, &controller->gamepad.buttons[i], error, error_size)) { return 0; } - snprintf(key, sizeof(key), "input.controller.joystick.buttons.%d", i + 1); - if (!host_config_controller_digital_binding(config, key, required, 0, + snprintf(key, sizeof(key), "%s.joystick.buttons.%d", base, i + 1); + if (!host_config_controller_digital_binding(config, key, required && i < 16, 0, &controller->joystick.buttons[i], error, error_size)) { return 0; } @@ -899,39 +914,36 @@ static int host_config_controller_buttons(const AeronConfigFile* config, int req } static int host_config_controller_pov(const AeronConfigFile* config, int required, - XwaControllerOptions* controller, char* error, size_t error_size) { - const AeronConfigNode* node = AeronConfigFile_GetNode(config, "input.controller.gamepad.pov"); + const char* base, XwaControllerOptions* controller, + char* error, size_t error_size) { + char key[128]; + const AeronConfigNode* node; + int axis, btn; const char* value; - + snprintf(key, sizeof key, "%s.gamepad.pov", base); + node = AeronConfigFile_GetNode(config, key); if (!node) { - if (!host_config_input_missing(required, "input.controller.gamepad.pov", error, error_size)) { - return 0; - } + if (!host_config_input_missing(required, key, error, error_size)) return 0; } else { value = AeronConfigNode_String(node, NULL); - if (value && strcmp(value, "dpad") == 0) { - controller->gamepad.pov_source = 1; - } else if (value && strcmp(value, "none") == 0) { - controller->gamepad.pov_source = 0; - } else { - return host_config_error(error, error_size, "invalid input setting '%s'", - "input.controller.gamepad.pov"); - } + if (value && strcmp(value, "dpad") == 0) controller->gamepad.pov_source = 1; + else if (value && strcmp(value, "none") == 0) controller->gamepad.pov_source = 0; + else return host_config_error(error, error_size, "invalid input setting '%s'", key); } - return host_config_raw_source(config, "input.controller.joystick.pov_hat", required, - AERON_CONTROLLER_HAT_MAX, &controller->joystick.pov_source, error, - error_size); + snprintf(key, sizeof key, "%s.joystick.pov_hat", base); + return host_config_raw_source(config, key, required, AERON_CONTROLLER_HAT_MAX, + &controller->joystick.pov_source, error, error_size); } static int host_config_controller_actions(const AeronConfigFile* config, int required, - const char* profile_name, XwaControllerProfile* profile, + const char* base, const char* profile_name, XwaControllerProfile* profile, char* error, size_t error_size) { char key[96]; const AeronConfigNode* node; size_t count; int i; - snprintf(key, sizeof(key), "input.controller.%s.actions", profile_name); + snprintf(key, sizeof(key), "%s.%s.actions", base, profile_name); node = AeronConfigFile_GetNode(config, key); if (!node) { return host_config_input_missing(required, key, error, error_size); @@ -940,50 +952,90 @@ static int host_config_controller_actions(const AeronConfigFile* config, int req return host_config_error(error, error_size, "invalid input setting '%s'", key); } count = AeronConfigNode_SequenceCount(node); - if (count != XWA_CONTROLLER_ACTION_COUNT) { + if (count != XWA_CONTROLLER_ACTION_COUNT && count != 20) { return host_config_error(error, error_size, "invalid input setting '%s'", key); } - for (i = 0; i < XWA_CONTROLLER_ACTION_COUNT; ++i) { + for (i = 0; i < (int)count; ++i) { const AeronConfigNode* item = AeronConfigNode_SequenceGet(node, (size_t)i); int64_t value = AeronConfigNode_Int(item, -1); if (AeronConfigNode_Type(item) != AERON_CONFIG_INT || value < 0 || value > UINT16_MAX) { return host_config_error(error, error_size, "invalid input setting '%s'", key); } - profile->actions[i] = (uint16_t)value; + profile->actions[count == 20 && i >= 16 + ? XWA_CONTROLLER_LOGICAL_BUTTON_COUNT + i - 16 : i] = (uint16_t)value; } return 1; } static int host_config_controller_options(const AeronConfigFile* config, int required, - XwaControllerOptions* controller, char* error, size_t error_size) { - if (!host_config_input_string(config, "input.controller.device.guid", required, controller->device.guid, - sizeof(controller->device.guid), error, error_size) || - !host_config_input_string(config, "input.controller.device.path", required, controller->device.path, - sizeof(controller->device.path), error, error_size) || - !host_config_input_int(config, "input.controller.device.ordinal", required, 0, - XWA_CONTROLLER_DEVICE_ORDINAL_MAX, &controller->device.ordinal, error, - error_size)) { + const char* base, XwaControllerOptions* controller, + char* error, size_t error_size) { + char key[128]; + snprintf(key, sizeof key, "%s.enabled", base); + if (!host_config_input_bool(config, key, 0, &controller->enabled, error, error_size)) return 0; + snprintf(key, sizeof key, "%s.device.guid", base); + if (!host_config_input_string(config, key, required, controller->device.guid, + sizeof(controller->device.guid), error, error_size)) return 0; + snprintf(key, sizeof key, "%s.device.path", base); + if (!host_config_input_string(config, key, required, controller->device.path, + sizeof(controller->device.path), error, error_size)) return 0; + snprintf(key, sizeof key, "%s.device.ordinal", base); + if (!host_config_input_int(config, key, required, 0, XWA_CONTROLLER_DEVICE_ORDINAL_MAX, + &controller->device.ordinal, error, error_size)) return 0; + snprintf(key, sizeof key, "%s.roll_enabled", base); + if (!host_config_input_bool(config, key, required, &controller->roll_enabled, error, error_size)) return 0; + snprintf(key, sizeof key, "%s.rumble_enabled", base); + if (!host_config_input_bool(config, key, required, &controller->rumble_enabled, error, error_size)) return 0; + snprintf(key, sizeof key, "%s.rumble_strength", base); + return host_config_input_int(config, key, required, XWA_CONTROLLER_RUMBLE_STRENGTH_MIN, + XWA_CONTROLLER_RUMBLE_STRENGTH_MAX, &controller->rumble_strength, error, error_size) && + host_config_controller_axes(config, required, base, controller, error, error_size) && + host_config_controller_buttons(config, required, base, controller, error, error_size) && + host_config_controller_pov(config, required, base, controller, error, error_size) && + host_config_controller_actions(config, required, base, "gamepad", &controller->gamepad, error, error_size) && + host_config_controller_actions(config, required, base, "joystick", &controller->joystick, error, error_size); +} + +static int host_config_head_tracking(const AeronConfigFile* config, int required, + XwaHeadTrackingOptions* head, char* error, size_t error_size) { + static const char* const axis_names[XWA_HEAD_TRACK_AXIS_COUNT] = { + "yaw", "pitch", "roll", "x", "y", "z" + }; + const AeronConfigNode* source = AeronConfigFile_GetNode(config, "input.head_tracking.source"); + const char* name; + char key[80]; + int axis; + + if (!host_config_input_bool(config, "input.head_tracking.enabled", required, &head->enabled, error, + error_size)) { return 0; } - return host_config_input_bool(config, "input.controller.roll_enabled", required, - &controller->roll_enabled, error, error_size) && - host_config_input_bool(config, "input.controller.rumble_enabled", required, - &controller->rumble_enabled, error, error_size) && - host_config_input_int(config, "input.controller.rumble_strength", required, - XWA_CONTROLLER_RUMBLE_STRENGTH_MIN, XWA_CONTROLLER_RUMBLE_STRENGTH_MAX, - &controller->rumble_strength, error, error_size) && - host_config_controller_axes(config, required, controller, error, error_size) && - host_config_controller_buttons(config, required, controller, error, error_size) && - host_config_controller_pov(config, required, controller, error, error_size) && - host_config_controller_actions(config, required, "gamepad", &controller->gamepad, error, - error_size) && - host_config_controller_actions(config, required, "joystick", &controller->joystick, error, - error_size); + if (!source) { + if (!host_config_input_missing(required, "input.head_tracking.source", error, error_size)) + return 0; + } else { + name = AeronConfigNode_String(source, NULL); + if (name && strcmp(name, "trackir") == 0) { + head->source = XWA_HEAD_TRACK_SOURCE_TRACKIR; + } else if (name && strcmp(name, "opentrack_udp") == 0) { + head->source = XWA_HEAD_TRACK_SOURCE_OPENTRACK_UDP; + } else { + return host_config_error(error, error_size, "invalid head tracking source: expected %s", + "'trackir' or 'opentrack_udp'"); + } + } + for (axis = 0; axis < XWA_HEAD_TRACK_AXIS_COUNT; ++axis) { + snprintf(key, sizeof key, "input.head_tracking.invert.%s", axis_names[axis]); + if (!host_config_input_bool(config, key, required, &head->invert[axis], error, error_size)) + return 0; + } + return 1; } static int host_config_input_options(const AeronConfigFile* config, int required, XwaModernInputOptions* out, char* error, size_t error_size) { const AeronConfigNode* node; + int axis, btn; if (!host_config_input_bool(config, "input.mouse_flight", required, &out->mouse_flight_enabled, error, error_size) || @@ -992,10 +1044,39 @@ static int host_config_input_options(const AeronConfigFile* config, int required error_size) || !host_config_input_bool(config, "input.mouse_invert_y", required, &out->mouse_invert_y, error, error_size) || - !host_config_controller_options(config, required, &out->controller, error, error_size)) { + !host_config_controller_options(config, required, "input.controller", &out->controller, error, error_size) || + !host_config_head_tracking(config, required, &out->head_tracking, error, error_size)) { return 0; } + /* Preserve pre-upgrade configurations as Controller 1. Controller 2 starts + * disabled and unbound, while optional user keys configure it independently. */ + if (required) { + out->controller.enabled = 1; + out->controller2 = out->controller; + out->controller2.enabled = 0; + out->controller2.rumble_enabled = 0; + for (axis = 0; axis < XWA_CONTROLLER_LOGICAL_AXIS_COUNT; ++axis) { + out->controller2.gamepad.axes[axis].source = -1; + out->controller2.joystick.axes[axis].source = -1; + } + for (btn = 0; btn < XWA_CONTROLLER_LOGICAL_BUTTON_COUNT; ++btn) { + /* A disabled binding must have kind NONE *and* index zero. + * Clearing only kind leaves copied legacy button indices, which + * the profile validator rejects during shipped config startup. */ + memset(&out->controller2.gamepad.buttons[btn], 0, + sizeof(out->controller2.gamepad.buttons[btn])); + memset(&out->controller2.joystick.buttons[btn], 0, + sizeof(out->controller2.joystick.buttons[btn])); + } + memset(out->controller2.gamepad.actions, 0, sizeof out->controller2.gamepad.actions); + memset(out->controller2.joystick.actions, 0, sizeof out->controller2.joystick.actions); + out->controller2.gamepad.pov_source = 0; + out->controller2.joystick.pov_source = -1; + } + if (!host_config_controller_options(config, 0, "input.controller2", &out->controller2, + error, error_size)) return 0; + node = AeronConfigFile_GetNode(config, "input.mouse_mode"); if (!node) { if (!host_config_input_missing(required, "input.mouse_mode", error, error_size)) { @@ -1014,13 +1095,15 @@ static int host_config_input_options(const AeronConfigFile* config, int required } if (!XwaModernInputOptions_Validate(out)) { return host_config_error(error, error_size, "invalid input configuration in %s", - required ? "remaster/config.yaml" : "config.yaml"); + required ? "remaster/config.yaml" : XWA_HOST_USER_CONFIG_FILENAME); } return 1; } static int host_config_validate_input_maps(const AeronConfigFile* config, char* error, size_t error_size) { static const char* const map_paths[] = { + "input.head_tracking", + "input.head_tracking.invert", "input.controller", "input.controller.device", "input.controller.gamepad", @@ -1029,6 +1112,7 @@ static int host_config_validate_input_maps(const AeronConfigFile* config, char* "input.controller.gamepad.axes.pitch", "input.controller.gamepad.axes.throttle", "input.controller.gamepad.axes.roll", + "input.controller.gamepad.axes.hyperdrive", "input.controller.gamepad.buttons", "input.controller.joystick", "input.controller.joystick.axes", @@ -1036,7 +1120,26 @@ static int host_config_validate_input_maps(const AeronConfigFile* config, char* "input.controller.joystick.axes.pitch", "input.controller.joystick.axes.throttle", "input.controller.joystick.axes.roll", + "input.controller.joystick.axes.hyperdrive", "input.controller.joystick.buttons", + "input.controller2", + "input.controller2.device", + "input.controller2.gamepad", + "input.controller2.gamepad.axes", + "input.controller2.gamepad.axes.yaw", + "input.controller2.gamepad.axes.pitch", + "input.controller2.gamepad.axes.throttle", + "input.controller2.gamepad.axes.roll", + "input.controller2.gamepad.axes.hyperdrive", + "input.controller2.gamepad.buttons", + "input.controller2.joystick", + "input.controller2.joystick.axes", + "input.controller2.joystick.axes.yaw", + "input.controller2.joystick.axes.pitch", + "input.controller2.joystick.axes.throttle", + "input.controller2.joystick.axes.roll", + "input.controller2.joystick.axes.hyperdrive", + "input.controller2.joystick.buttons", }; size_t i; @@ -1050,7 +1153,7 @@ static int host_config_validate_input_maps(const AeronConfigFile* config, char* } int XwaHostConfig_Load(AeronVfs* vfs, XwaHostConfig* out, char* error, size_t error_size) { - static const char* path = "config.yaml"; + static const char* path = XWA_HOST_USER_CONFIG_FILENAME; AeronConfigFile* config = NULL; if (!vfs || !out) { return host_config_error(error, error_size, "cannot load %s", path); @@ -1133,7 +1236,7 @@ int XwaHostConfig_Load(AeronVfs* vfs, XwaHostConfig* out, char* error, size_t er } int XwaHostConfig_LoadPaths(AeronVfs* vfs, XwaHostConfig* out, char* error, size_t error_size) { - static const char* path = "config.yaml"; + static const char* path = XWA_HOST_USER_CONFIG_FILENAME; AeronConfigFile* config = NULL; const AeronConfigNode* version; const AeronConfigNode* paths; @@ -1180,14 +1283,14 @@ static int host_config_prepare_user_document(AeronVfs* vfs, AeronConfigFile** do size_t error_size) { AeronConfigError config_error = { 0 }; - if (AeronVfs_Exists(vfs, AERON_VFS_ROOT_USER, "config.yaml")) { - if (AeronConfigFile_LoadYamlEx(vfs, AERON_VFS_ROOT_USER, "config.yaml", document, &config_error)) { + if (AeronVfs_Exists(vfs, AERON_VFS_ROOT_USER, XWA_HOST_USER_CONFIG_FILENAME)) { + if (AeronConfigFile_LoadYamlEx(vfs, AERON_VFS_ROOT_USER, XWA_HOST_USER_CONFIG_FILENAME, document, &config_error)) { return 1; } return host_config_error(error, error_size, "could not update user configuration: %s", config_error.message); } - if (!AeronConfigFile_CreateMap(AERON_VFS_ROOT_USER, "config.yaml", document, &config_error) || + if (!AeronConfigFile_CreateMap(AERON_VFS_ROOT_USER, XWA_HOST_USER_CONFIG_FILENAME, document, &config_error) || !AeronConfigFile_SetInt(*document, "version", 1, &config_error)) { AeronConfigFile_Destroy(*document); *document = NULL; @@ -1235,7 +1338,8 @@ static int host_config_set_video_options(AeronConfigFile* document, const XwaMod options->sdr_gamma > XWA_MODERN_SDR_GAMMA_SRGB || options->paper_white < XWA_MODERN_PAPER_WHITE_AUTO || options->paper_white > XWA_MODERN_PAPER_WHITE_400 || !isfinite(options->hud_scale) || - options->hud_scale < XWA_MODERN_HUD_SCALE_MIN || options->hud_scale > XWA_MODERN_HUD_SCALE_MAX) { + options->hud_scale < XWA_MODERN_HUD_SCALE_MIN || options->hud_scale > XWA_MODERN_HUD_SCALE_MAX || + (options->smooth_menu_fonts != 0 && options->smooth_menu_fonts != 1)) { return 0; } return AeronConfigFile_SetString(document, "video.window_mode", window_mode_names[options->window_mode], @@ -1256,7 +1360,8 @@ static int host_config_set_video_options(AeronConfigFile* document, const XwaMod error) && AeronConfigFile_SetString(document, "video.paper_white_nits", paper_white_names[options->paper_white], error) && - AeronConfigFile_SetFloat(document, "video.hud_scale", options->hud_scale, error); + AeronConfigFile_SetFloat(document, "video.hud_scale", options->hud_scale, error) && + AeronConfigFile_SetBool(document, "video.smooth_menu_fonts", options->smooth_menu_fonts, error); } int XwaHostConfig_SaveGameDataPath(AeronVfs* vfs, const char* game_data_path, char* error, @@ -1266,7 +1371,7 @@ int XwaHostConfig_SaveGameDataPath(AeronVfs* vfs, const char* game_data_path, ch if (!vfs || !game_data_path || !game_data_path[0] || strlen(game_data_path) >= XWA_HOST_CONFIG_PATH_CAPACITY) { - return host_config_error(error, error_size, "cannot save invalid path to %s", "config.yaml"); + return host_config_error(error, error_size, "cannot save invalid path to %s", XWA_HOST_USER_CONFIG_FILENAME); } if (!host_config_prepare_user_document(vfs, &document, error, error_size)) { return 0; @@ -1284,7 +1389,7 @@ int XwaHostConfig_SaveModsPath(AeronVfs* vfs, const char* mods_path, char* error AeronConfigError config_error = { 0 }; if (!vfs || !mods_path || !mods_path[0] || strlen(mods_path) >= XWA_HOST_CONFIG_PATH_CAPACITY) { - return host_config_error(error, error_size, "cannot save invalid path to %s", "config.yaml"); + return host_config_error(error, error_size, "cannot save invalid path to %s", XWA_HOST_USER_CONFIG_FILENAME); } if (!host_config_prepare_user_document(vfs, &document, error, error_size)) { return 0; @@ -1304,7 +1409,7 @@ int XwaHostConfig_SaveModPath(AeronVfs* vfs, const char* mod_path, int use_mod, int updated; if (!vfs || (mod_path && strlen(mod_path) >= XWA_HOST_CONFIG_PATH_CAPACITY)) { - return host_config_error(error, error_size, "cannot save invalid path to %s", "config.yaml"); + return host_config_error(error, error_size, "cannot save invalid path to %s", XWA_HOST_USER_CONFIG_FILENAME); } if (!host_config_prepare_user_document(vfs, &document, error, error_size)) { return 0; @@ -1324,7 +1429,7 @@ int XwaHostConfig_SaveModPath(AeronVfs* vfs, const char* mod_path, int use_mod, return host_config_save_user_document(vfs, document, error, error_size); } -static int host_config_set_controller_axis(AeronConfigFile* document, const char* profile_name, +static int host_config_set_controller_axis(AeronConfigFile* document, const char* base, const char* profile_name, const char* name, const XwaControllerAxisBinding* binding, int gamepad, AeronConfigError* error) { char path[128]; @@ -1338,7 +1443,7 @@ static int host_config_set_controller_axis(AeronConfigFile* document, const char return 0; } } - snprintf(path, sizeof path, "input.controller.%s.axes.%s.source", profile_name, name); + snprintf(path, sizeof path, "%s.%s.axes.%s.source", base, profile_name, name); if (gamepad || binding->source < 0) { if (!AeronConfigFile_SetString(document, path, source_name, error)) { return 0; @@ -1346,21 +1451,21 @@ static int host_config_set_controller_axis(AeronConfigFile* document, const char } else if (!AeronConfigFile_SetInt(document, path, binding->source, error)) { return 0; } - snprintf(path, sizeof path, "input.controller.%s.axes.%s.invert", profile_name, name); + snprintf(path, sizeof path, "%s.%s.axes.%s.invert", base, profile_name, name); if (!AeronConfigFile_SetBool(document, path, binding->invert, error)) { return 0; } - snprintf(path, sizeof path, "input.controller.%s.axes.%s.deadzone", profile_name, name); + snprintf(path, sizeof path, "%s.%s.axes.%s.deadzone", base, profile_name, name); return AeronConfigFile_SetFloat(document, path, binding->deadzone, error); } -static int host_config_set_controller_button(AeronConfigFile* document, const char* profile_name, +static int host_config_set_controller_button(AeronConfigFile* document, const char* base, const char* profile_name, const char* key, const AeronControllerDigitalSource* binding, int gamepad, AeronConfigError* error) { char path[128]; const char* source_name; - snprintf(path, sizeof path, "input.controller.%s.buttons.%s", profile_name, key); + snprintf(path, sizeof path, "%s.%s.buttons.%s", base, profile_name, key); if (binding->kind == AERON_CONTROLLER_DIGITAL_NONE) { return AeronConfigFile_SetString(document, path, "none", error); @@ -1408,7 +1513,7 @@ static int host_config_set_controller_button(AeronConfigFile* document, const ch return 0; } -static int host_config_set_controller_actions(AeronConfigFile* document, const char* profile_name, +static int host_config_set_controller_actions(AeronConfigFile* document, const char* base, const char* profile_name, const uint16_t actions[XWA_CONTROLLER_ACTION_COUNT], AeronConfigError* error) { AeronConfigValue items[XWA_CONTROLLER_ACTION_COUNT]; @@ -1422,20 +1527,21 @@ static int host_config_set_controller_actions(AeronConfigFile* document, const c } sequence.value.sequence.values = items; sequence.value.sequence.count = XWA_CONTROLLER_ACTION_COUNT; - snprintf(path, sizeof path, "input.controller.%s.actions", profile_name); + snprintf(path, sizeof path, "%s.%s.actions", base, profile_name); return AeronConfigFile_SetValue(document, path, &sequence, error); } -static int host_config_set_controller_profile(AeronConfigFile* document, const char* profile_name, +static int host_config_set_controller_profile(AeronConfigFile* document, const char* base, const char* profile_name, const XwaControllerProfile* profile, int gamepad, AeronConfigError* error) { - static const char* const axis_names[XWA_CONTROLLER_LOGICAL_AXIS_COUNT] = { "yaw", "pitch", "throttle", - "roll" }; + static const char* const axis_names[XWA_CONTROLLER_LOGICAL_AXIS_COUNT] = { + "yaw", "pitch", "throttle", "roll", "hyperdrive" + }; char path[96]; int i; for (i = 0; i < XWA_CONTROLLER_LOGICAL_AXIS_COUNT; ++i) { - if (!host_config_set_controller_axis(document, profile_name, axis_names[i], &profile->axes[i], + if (!host_config_set_controller_axis(document, base, profile_name, axis_names[i], &profile->axes[i], gamepad, error)) { return 0; } @@ -1444,18 +1550,18 @@ static int host_config_set_controller_profile(AeronConfigFile* document, const c char key[8]; snprintf(key, sizeof(key), "%d", i + 1); - if (!host_config_set_controller_button(document, profile_name, key, &profile->buttons[i], gamepad, + if (!host_config_set_controller_button(document, base, profile_name, key, &profile->buttons[i], gamepad, error)) { return 0; } } if (gamepad) { - snprintf(path, sizeof path, "input.controller.%s.pov", profile_name); + snprintf(path, sizeof path, "%s.%s.pov", base, profile_name); if (!AeronConfigFile_SetString(document, path, profile->pov_source ? "dpad" : "none", error)) { return 0; } } else { - snprintf(path, sizeof path, "input.controller.%s.pov_hat", profile_name); + snprintf(path, sizeof path, "%s.%s.pov_hat", base, profile_name); if (profile->pov_source >= 0) { if (!AeronConfigFile_SetInt(document, path, profile->pov_source, error)) { return 0; @@ -1464,34 +1570,62 @@ static int host_config_set_controller_profile(AeronConfigFile* document, const c return 0; } } - return host_config_set_controller_actions(document, profile_name, profile->actions, error); + return host_config_set_controller_actions(document, base, profile_name, profile->actions, error); } -static int host_config_set_input_options(AeronConfigFile* document, const XwaModernInputOptions* options, +static int host_config_set_head_tracking(AeronConfigFile* document, const XwaHeadTrackingOptions* head, AeronConfigError* error) { - static const char* const mode_names[] = { "position", "rate" }; - - if (!XwaModernInputOptions_Validate(options)) { + static const char* const axis_names[XWA_HEAD_TRACK_AXIS_COUNT] = { + "yaw", "pitch", "roll", "x", "y", "z" + }; + char key[80]; + int axis; + if (!AeronConfigFile_SetBool(document, "input.head_tracking.enabled", head->enabled, error) || + !AeronConfigFile_SetString( + document, "input.head_tracking.source", + head->source == XWA_HEAD_TRACK_SOURCE_OPENTRACK_UDP ? "opentrack_udp" : "trackir", error)) { return 0; } + for (axis = 0; axis < XWA_HEAD_TRACK_AXIS_COUNT; ++axis) { + snprintf(key, sizeof key, "input.head_tracking.invert.%s", axis_names[axis]); + if (!AeronConfigFile_SetBool(document, key, head->invert[axis], error)) + return 0; + } + return 1; +} + +static int host_config_set_controller_options(AeronConfigFile* document, const char* base, + const XwaControllerOptions* controller, AeronConfigError* error) { + char path[128]; + snprintf(path, sizeof path, "%s.enabled", base); + if (!AeronConfigFile_SetBool(document, path, controller->enabled, error)) return 0; + snprintf(path, sizeof path, "%s.device.guid", base); + if (!AeronConfigFile_SetString(document, path, controller->device.guid, error)) return 0; + snprintf(path, sizeof path, "%s.device.path", base); + if (!AeronConfigFile_SetString(document, path, controller->device.path, error)) return 0; + snprintf(path, sizeof path, "%s.device.ordinal", base); + if (!AeronConfigFile_SetInt(document, path, controller->device.ordinal, error)) return 0; + snprintf(path, sizeof path, "%s.roll_enabled", base); + if (!AeronConfigFile_SetBool(document, path, controller->roll_enabled, error)) return 0; + snprintf(path, sizeof path, "%s.rumble_enabled", base); + if (!AeronConfigFile_SetBool(document, path, controller->rumble_enabled, error)) return 0; + snprintf(path, sizeof path, "%s.rumble_strength", base); + return AeronConfigFile_SetInt(document, path, controller->rumble_strength, error) && + host_config_set_controller_profile(document, base, "gamepad", &controller->gamepad, 1, error) && + host_config_set_controller_profile(document, base, "joystick", &controller->joystick, 0, error); +} + +static int host_config_set_input_options(AeronConfigFile* document, const XwaModernInputOptions* options, + AeronConfigError* error) { + static const char* const mode_names[] = { "position", "rate" }; + if (!XwaModernInputOptions_Validate(options)) return 0; return AeronConfigFile_SetBool(document, "input.mouse_flight", options->mouse_flight_enabled, error) && - AeronConfigFile_SetString(document, "input.mouse_mode", mode_names[options->mouse_mode], error) && - AeronConfigFile_SetInt(document, "input.mouse_sensitivity", options->mouse_sensitivity, error) && - AeronConfigFile_SetBool(document, "input.mouse_invert_y", options->mouse_invert_y, error) && - AeronConfigFile_SetString(document, "input.controller.device.guid", - options->controller.device.guid, error) && - AeronConfigFile_SetString(document, "input.controller.device.path", - options->controller.device.path, error) && - AeronConfigFile_SetInt(document, "input.controller.device.ordinal", - options->controller.device.ordinal, error) && - AeronConfigFile_SetBool(document, "input.controller.roll_enabled", - options->controller.roll_enabled, error) && - AeronConfigFile_SetBool(document, "input.controller.rumble_enabled", - options->controller.rumble_enabled, error) && - AeronConfigFile_SetInt(document, "input.controller.rumble_strength", - options->controller.rumble_strength, error) && - host_config_set_controller_profile(document, "gamepad", &options->controller.gamepad, 1, error) && - host_config_set_controller_profile(document, "joystick", &options->controller.joystick, 0, error); + AeronConfigFile_SetString(document, "input.mouse_mode", mode_names[options->mouse_mode], error) && + AeronConfigFile_SetInt(document, "input.mouse_sensitivity", options->mouse_sensitivity, error) && + AeronConfigFile_SetBool(document, "input.mouse_invert_y", options->mouse_invert_y, error) && + host_config_set_head_tracking(document, &options->head_tracking, error) && + host_config_set_controller_options(document, "input.controller", &options->controller, error) && + host_config_set_controller_options(document, "input.controller2", &options->controller2, error); } int XwaHostConfig_SaveInputOptions(AeronVfs* vfs, const XwaModernInputOptions* options, char* error, @@ -1501,7 +1635,7 @@ int XwaHostConfig_SaveInputOptions(AeronVfs* vfs, const XwaModernInputOptions* o if (!vfs || !options) { return host_config_error(error, error_size, "cannot save invalid input settings to %s", - "config.yaml"); + XWA_HOST_USER_CONFIG_FILENAME); } if (!host_config_prepare_user_document(vfs, &document, error, error_size)) { return 0; @@ -1521,7 +1655,7 @@ int XwaHostConfig_SaveVideoOptions(AeronVfs* vfs, const XwaModernVideoOptions* o if (!vfs || !options) { return host_config_error(error, error_size, "cannot save invalid video settings to %s", - "config.yaml"); + XWA_HOST_USER_CONFIG_FILENAME); } if (!host_config_prepare_user_document(vfs, &document, error, error_size)) { return 0; @@ -1539,7 +1673,7 @@ int XwaHostConfig_SaveSwitch(AeronVfs* vfs, const char* key, int value, char* er AeronConfigError config_error = { 0 }; if (!vfs || !key) { - return host_config_error(error, error_size, "cannot save a switch to %s", "config.yaml"); + return host_config_error(error, error_size, "cannot save a switch to %s", XWA_HOST_USER_CONFIG_FILENAME); } if (!host_config_prepare_user_document(vfs, &document, error, error_size)) { return 0; diff --git a/src/xwa_app/host_config.h b/src/xwa_app/host_config.h index e549ae57..3f507a2b 100644 --- a/src/xwa_app/host_config.h +++ b/src/xwa_app/host_config.h @@ -13,6 +13,8 @@ extern "C" { #define XWA_HOST_CONFIG_PATH_CAPACITY 1024 #define XWA_HOST_CONFIG_MANIFEST_CAPACITY 256 +/* User-writable settings for this private dual-controller build only. */ +#define XWA_HOST_USER_CONFIG_FILENAME "config.dual-controller.yaml" typedef struct XwaHostConfig { char game_data_path[XWA_HOST_CONFIG_PATH_CAPACITY]; diff --git a/src/xwa_app/main.c b/src/xwa_app/main.c index 7decd1eb..6df8d8e3 100644 --- a/src/xwa_app/main.c +++ b/src/xwa_app/main.c @@ -87,6 +87,7 @@ static void apply_modern_input_options(const XwaModernInputOptions* options) { XwaMouseFlight_SetOptions(options); if (options) { XwaControllerMapping_SetOptions(&options->controller); + XwaControllerMapping_SetSecondaryOptions(&options->controller2); Config_ApplyModernInputOptions(options); } } @@ -393,8 +394,8 @@ int main(int argc, char** argv) { } if (!XwaHostConfig_Load(Aeron_GetVfs(), &host_config, config_error, sizeof config_error)) { char message[1536]; - snprintf(message, sizeof message, "%s\nConfiguration path: %s/config.yaml", config_error, - Aeron_UserPath()); + snprintf(message, sizeof message, "%s\nConfiguration path: %s/%s", config_error, + Aeron_UserPath(), XWA_HOST_USER_CONFIG_FILENAME); Aeron_LogError("xwa.config", "%s", message); show_setup_error(message); Aeron_Shutdown(); diff --git a/src/xwa_remaster/assets.c b/src/xwa_remaster/assets.c index f20575ac..acc6bc81 100644 --- a/src/xwa_remaster/assets.c +++ b/src/xwa_remaster/assets.c @@ -43,7 +43,8 @@ static uint32_t assets_next_generation; typedef struct FontSlot { int font_size; uint8_t source; /* AssetSource */ - AeronFontAtlas atlas; /* loaded == 0 when the load failed */ + AeronFontAtlas atlas; /* default 4x atlas; loaded == 0 on failure */ + AeronFontAtlas smooth_atlas; /* optional original 1x atlas */ } FontSlot; /* An atlas of a font from frontend/fonts/fonts.yaml or flight/fonts/fonts.yaml @@ -140,6 +141,7 @@ typedef struct GroupSlot { struct XwaRemasterAssets { char root[512]; int prefer_original_2d; + int smooth_menu_fonts; XwaRemasterOriginal2d* original_reader; AeronImageCache* frontend_images; AeronImageCache* flight_images; @@ -210,6 +212,7 @@ void XwaRemasterAssets_Destroy(XwaRemasterAssets* a) { if (a->fonts[i].atlas.loaded) { AeronFontAtlas_Release(&a->fonts[i].atlas); } + AeronFontAtlas_Release(&a->fonts[i].smooth_atlas); } for (int i = 0; i < XWA_ASSETS_MAX_FACE_FONTS; i++) { AeronFontAtlas_Release(&a->face_fonts[i].atlas); @@ -1491,6 +1494,11 @@ const AeronFontAtlas* XwaRemasterAssets_FlightFont(XwaRemasterAssets* a, int tie return slot->atlas.loaded ? &slot->atlas : NULL; } +void XwaRemasterAssets_SetSmoothMenuFonts(XwaRemasterAssets* a, int enabled) { + if (a) + a->smooth_menu_fonts = enabled != 0; +} + uint32_t XwaRemasterAssets_Generation(const XwaRemasterAssets* a) { return a ? a->generation : 0; } static AssetLoadStatus assets_load_original_frontend_font(XwaRemasterAssets* a, AeronCommandBuffer* cmd, @@ -1509,6 +1517,11 @@ static AssetLoadStatus assets_load_original_frontend_font(XwaRemasterAssets* a, Xwa2dFontAtlas_Free(&font); return ASSET_LOAD_FAILED; } + /* Load both variants so the menu can switch at once. The 1x atlas is + optional: when it fails, the 4x font is still used. */ + if (!assets_init_original_font(cmd, &font, 1, 0, "XWA smooth frontend font", &slot->smooth_atlas)) { + Aeron_LogWarn("xwa.remaster", "frontend font %d: optional smooth atlas unavailable", font_size); + } Xwa2dFontAtlas_Free(&font); return ASSET_LOAD_SUCCESS; } @@ -1658,10 +1671,16 @@ const AeronFontAtlas* XwaRemasterAssets_FrontendFont(XwaRemasterAssets* a, int f return NULL; } for (int i = 0; i < a->font_count; i++) { - if (a->fonts[i].font_size == font_size) { + const FontSlot* slot = &a->fonts[i]; + if (slot->font_size == font_size) { + if (a->smooth_menu_fonts && slot->source == ASSET_SOURCE_ORIGINAL && slot->smooth_atlas.loaded) { + if (out_atlas_scale) + *out_atlas_scale = 1.0f; + return &slot->smooth_atlas; + } if (out_atlas_scale) *out_atlas_scale = 4.0f; - return a->fonts[i].atlas.loaded ? &a->fonts[i].atlas : NULL; + return slot->atlas.loaded ? &slot->atlas : NULL; } } return NULL; diff --git a/src/xwa_remaster/assets.h b/src/xwa_remaster/assets.h index 246e66d4..b46dc6a8 100644 --- a/src/xwa_remaster/assets.h +++ b/src/xwa_remaster/assets.h @@ -64,6 +64,8 @@ typedef struct XwaAssetRef { * source order; the alternate source is selected only when the first is absent. */ XwaRemasterAssets* XwaRemasterAssets_Create(const char* root, int prefer_original_2d); void XwaRemasterAssets_Destroy(XwaRemasterAssets* a); +/* Select a resident original frontend atlas without reloading GPU resources. */ +void XwaRemasterAssets_SetSmoothMenuFonts(XwaRemasterAssets* a, int enabled); /* Frontend residency mirrors the classic named-resource and DAT-group sets. * Sync performs all I/O/upload before reconstruction; sprite and font entry @@ -112,7 +114,7 @@ const AeronFontAtlas* XwaRemasterAssets_FlightFont(XwaRemasterAssets* a, int tie uint32_t XwaRemasterAssets_Generation(const XwaRemasterAssets* a); /* Font atlas for a classic point size, or NULL when neither source loaded. - * Both authored and runtime-original atlases use a 4x pixel scale. + * Authored fonts use 4x; original bitmap fonts may use native 1x scale. * Borrowed; owned by the resolver. */ const AeronFontAtlas* XwaRemasterAssets_FrontendFont(XwaRemasterAssets* a, int font_size, float* out_atlas_scale); diff --git a/src/xwa_remaster/flight.c b/src/xwa_remaster/flight.c index fd4ca32d..7a40b945 100644 --- a/src/xwa_remaster/flight.c +++ b/src/xwa_remaster/flight.c @@ -3227,7 +3227,8 @@ static int fl_temporal_pose_changed(const XwaSnapshot* current, const XwaSnapsho cockpit->aim_angle_b != prev_cockpit->aim_angle_b || memcmp(cockpit->seat_eye_world_offset, prev_cockpit->seat_eye_world_offset, sizeof cockpit->seat_eye_world_offset) != 0 || - memcmp(cockpit->camera_pan, prev_cockpit->camera_pan, sizeof cockpit->camera_pan) != 0; + memcmp(cockpit->camera_pan, prev_cockpit->camera_pan, sizeof cockpit->camera_pan) != 0 || + memcmp(cockpit->head_offset, prev_cockpit->head_offset, sizeof cockpit->head_offset) != 0; } /* Rotate inverted gunner cockpits about the model-local X axis, @@ -3242,8 +3243,8 @@ static void fl_apply_inverted_cockpit_pivot(float basis[9], const XwaCockpit* co const float rotated_eye = basis[axis] * cockpit->seat_eye_local[0] + basis[3 + axis] * cockpit->seat_eye_local[1] + basis[6 + axis] * cockpit->seat_eye_local[2]; - position[axis] = camera_local[axis] - cockpit->camera_pan[axis] * 0.0625f - - sway[axis] - rotated_eye; + position[axis] = camera_local[axis] - cockpit->head_offset[axis] - + cockpit->camera_pan[axis] * 0.0625f - sway[axis] - rotated_eye; } } @@ -3274,8 +3275,11 @@ static int fl_cockpit_model_matrix(const XwaCockpit* cockpit, const float sway[3 float position[3]; for (int axis = 0; axis < 3; ++axis) { - position[axis] = camera_local[axis] + camera_rows[0 * 3 + axis] * delta[0] + - camera_rows[1 * 3 + axis] * delta[1] + camera_rows[2 * 3 + axis] * delta[2]; + /* Camera motion is observer-only. The cockpit is anchored to the ship, + * so remove the head displacement before constructing its world pose. */ + position[axis] = camera_local[axis] - cockpit->head_offset[axis] + + camera_rows[0 * 3 + axis] * delta[0] + camera_rows[1 * 3 + axis] * delta[1] + + camera_rows[2 * 3 + axis] * delta[2]; } if (cockpit->inverted) { fl_apply_inverted_cockpit_pivot(basis, cockpit, sway, camera_local, position); @@ -5468,8 +5472,11 @@ static void fl_submit_hyperspace_cockpit(AeronCommandBuffer* cmd, XwaRemasterAss fl_world_to_view(s.crows, w[0], w[1], w[2], delta); float pw[3]; for (int j = 0; j < 3; j++) { - pw[j] = s.camera_local[j] + s.crows[0 * 3 + j] * delta[0] + s.crows[1 * 3 + j] * delta[1] + - s.crows[2 * 3 + j] * delta[2]; + /* Head motion changes the observer, not the hyperspace cockpit. + * As in normal flight, keep the cockpit anchored at the untracked + * eye origin while the rendered camera translates around it. */ + pw[j] = s.camera_local[j] - snap->cockpit.head_offset[j] + s.crows[0 * 3 + j] * delta[0] + + s.crows[1 * 3 + j] * delta[1] + s.crows[2 * 3 + j] * delta[2]; } if (snap->cockpit.inverted) { fl_apply_inverted_cockpit_pivot(bw, &snap->cockpit, cam->sway, s.camera_local, pw); diff --git a/src/xwa_remaster/hud_fixed.c b/src/xwa_remaster/hud_fixed.c index c5731f1a..df7d465a 100644 --- a/src/xwa_remaster/hud_fixed.c +++ b/src/xwa_remaster/hud_fixed.c @@ -890,9 +890,21 @@ static int fixed_project_reticle(const XwaSnapshot* snapshot, XwaHudProfileIndex const float center_eye[3] = { 0.0f, 0.0f, 1.0f }; if (!XwaRemasterFlight_ProjectView(flight_view, center_eye, &target_x, &target_y)) return 0; - /* When the player looks around, and in the exterior HUD, the reticle - * follows the craft's nose instead of the screen center. */ - if (h->reticle.look_yaw || h->reticle.look_pitch || (h->mode_flags & XWA_HUD_MODE_EXTERIOR_HUD)) { + if (h->reticle.seat > 0 && h->reticle.turret_aim_valid) { + /* Project the gun's own aim direction, which head tracking does not + * change, through the head-tracked camera. Projecting ship-forward here was incorrect + * in turret seats, where the turret rotates independently. */ + const XwaFlightCamera* camera = &snapshot->flight_camera; + float bore_view[3]; + for (int row = 0; row < 3; ++row) { + bore_view[row] = camera->rows[row * 3 + 0] * h->reticle.turret_aim_world[0] + + camera->rows[row * 3 + 1] * h->reticle.turret_aim_world[1] + + camera->rows[row * 3 + 2] * h->reticle.turret_aim_world[2]; + } + if (bore_view[2] <= 0.0f || + !XwaRemasterFlight_ProjectView(flight_view, bore_view, &target_x, &target_y)) + return 0; + } else if (h->reticle.look_yaw || h->reticle.look_pitch || (h->mode_flags & XWA_HUD_MODE_EXTERIOR_HUD)) { const XwaFlightObject* player = fixed_player_object(snapshot, h); if (player && player->has_mobj) { float local[3]; diff --git a/src/xwa_remaster/xwa_remaster.c b/src/xwa_remaster/xwa_remaster.c index 817151d5..a60a5a9c 100644 --- a/src/xwa_remaster/xwa_remaster.c +++ b/src/xwa_remaster/xwa_remaster.c @@ -94,6 +94,7 @@ static struct { int flight_render_suspended; int hyperspace_preview_last; AeronSampleCount msaa_samples; + int smooth_menu_fonts; } g; /* Tells the presentation code how the frontend is shown, so the picture and @@ -250,7 +251,8 @@ static int XwaRemaster_VideoOptionsValid(const XwaModernVideoOptions* options) { options->sdr_gamma <= XWA_MODERN_SDR_GAMMA_SRGB && options->paper_white >= XWA_MODERN_PAPER_WHITE_AUTO && options->paper_white <= XWA_MODERN_PAPER_WHITE_400 && isfinite(options->hud_scale) && - options->hud_scale >= XWA_MODERN_HUD_SCALE_MIN && options->hud_scale <= XWA_MODERN_HUD_SCALE_MAX; + options->hud_scale >= XWA_MODERN_HUD_SCALE_MIN && options->hud_scale <= XWA_MODERN_HUD_SCALE_MAX && + (options->smooth_menu_fonts == 0 || options->smooth_menu_fonts == 1); } void XwaRemaster_GetVideoOptions(XwaModernVideoOptions* out) { @@ -281,6 +283,7 @@ void XwaRemaster_GetVideoOptions(XwaModernVideoOptions* out) { out->sdr_gamma = XwaRemaster_SdrGammaFromDecode(Aeron_OutputSdrContentGamma()); out->paper_white = XwaRemaster_PaperWhiteFromNits(Aeron_OutputPaperWhiteNits()); out->hud_scale = XwaRemasterHud_UserScale(); + out->smooth_menu_fonts = g.smooth_menu_fonts; } void XwaRemaster_SetVideoOptions(const XwaModernVideoOptions* options) { @@ -324,6 +327,8 @@ void XwaRemaster_SetVideoOptions(const XwaModernVideoOptions* options) { XwaRemaster_SetHdrDesired(options->hdr_output); XwaRemasterHud_SetUserScale(options->hud_scale); + g.smooth_menu_fonts = options->smooth_menu_fonts; + XwaRemasterAssets_SetSmoothMenuFonts(g.assets, g.smooth_menu_fonts); } int XwaRemaster_Init(const XwaRemasterInitOptions* options) { @@ -397,6 +402,9 @@ int XwaRemaster_Init(const XwaRemasterInitOptions* options) { if (video_override_mask & XWA_MODERN_VIDEO_OVERRIDE_HUD_SCALE) { video_options.hud_scale = options->video_options.hud_scale; } + if (video_override_mask & XWA_MODERN_VIDEO_OVERRIDE_SMOOTH_MENU_FONTS) { + video_options.smooth_menu_fonts = options->video_options.smooth_menu_fonts; + } XwaRemaster_SetVideoOptions(&video_options); g.hdr_apply_pending = 0; /* Aeron downgrades to SDR by itself when HDR is unavailable and re-applies @@ -409,6 +417,7 @@ int XwaRemaster_Init(const XwaRemasterInitOptions* options) { Aeron_LogInfo("xwa.remaster", "HDR output: desired %s, %s (headroom %.2f)", g.hdr_desired ? "on" : "off", Aeron_OutputHdrStatusName(Aeron_OutputHdrStatus()), (double)Aeron_OutputHdrHeadroom()); g.assets = XwaRemasterAssets_Create(Aeron_ModRoot(), options->prefer_original_2d); + XwaRemasterAssets_SetSmoothMenuFonts(g.assets, g.smooth_menu_fonts); Aeron_BlendRampInit(&g.ramp); g.last_tick = UINT64_MAX; /* Snapshot zero has not been rendered yet. */ g.mode = RM_VIEW_HD; diff --git a/src/xwa_runtime/config/modern_controller_options_screen.c b/src/xwa_runtime/config/modern_controller_options_screen.c index fd61c217..6a21d438 100644 --- a/src/xwa_runtime/config/modern_controller_options_screen.c +++ b/src/xwa_runtime/config/modern_controller_options_screen.c @@ -19,6 +19,7 @@ enum { CONTROLLER_KEY_DELETE = 0x2e, CONTROLLER_CAPTURE_THRESHOLD = 8192, CONTROLLER_PAGE_SIZE = 8, + CONTROLLER_AXIS_PAGE_SIZE = 3, }; typedef struct ControllerCaptureState { @@ -27,6 +28,7 @@ typedef struct ControllerCaptureState { int digital_axis; int wait_for_release; int16_t axis_baseline[AERON_CONTROLLER_AXIS_MAX]; + uint8_t pov_baseline[AERON_CONTROLLER_HAT_MAX]; } ControllerCaptureState; typedef struct ControllerBindingRow { @@ -49,7 +51,28 @@ typedef struct ControllerBindingEditState { static ControllerCaptureState g_controllerCapture = { -1, -1, -1, 0, { 0 } }; static ControllerBindingEditState g_controllerBindingEdit; +static int g_controllerAxisPage; static int g_controllerButtonPage; +static int g_controllerSlot; +static XwaControllerOptions* ControllerScreen_Options(XwaModernInputOptions* options) { + return g_controllerSlot ? &options->controller2 : &options->controller; +} +void XwaModernControllerOptionsScreen_SelectSlot(int slot) { + g_controllerSlot = slot == 1; + XwaModernControllerOptionsScreen_ResetCapture(); + g_controllerButtonPage = 0; + g_controllerAxisPage = 0; +} + +/* Rising-edge selection avoids latched throttle switches stealing focus. */ +static struct { + uint32_t instance_id; + int16_t axes[AERON_CONTROLLER_AXIS_MAX]; + uint8_t hats[AERON_CONTROLLER_HAT_MAX]; + uint64_t raw_buttons; + uint32_t gamepad_buttons; + int ready; +} g_controllerButtonScan; static char g_controllerBindingMessage[96]; static int g_controllerBindingMessageTtl; @@ -106,77 +129,63 @@ static const AeronControllerSnapshot* ControllerScreen_Selected(const AeronContr return controller; } -static void ControllerScreen_SelectDevice(AeronControllerSelector* selector, int direction) { +static void ControllerScreen_SelectDevice(XwaControllerOptions* options, int direction) { const AeronInputSnapshot* input = Aeron_InputSnapshot(); - const AeronControllerSnapshot* selected; - int connected_slots[AERON_CONTROLLER_MAX]; - int count = 0; - int current = 0; - int slot; - int i; - - if (!input) { - memset(selector, 0, sizeof(*selector)); - return; - } - selected = Aeron_SelectController(input, selector); - for (slot = 0; slot < AERON_CONTROLLER_MAX; ++slot) { - if (input->controllers[slot].connected) { - connected_slots[count++] = slot; - if (&input->controllers[slot] == selected) { - current = count; - } - } - } - if (selector->guid[0] && current == 0) { - current = count + 1; + AeronControllerSelector* selector = &options->device; + const AeronControllerSnapshot* selected = input && options->enabled + ? Aeron_SelectController(input, selector) : NULL; + int connected_slots[AERON_CONTROLLER_MAX], count = 0, current; + if (!input) return; + for (int slot = 0; slot < AERON_CONTROLLER_MAX; ++slot) + if (input->controllers[slot].connected) connected_slots[count++] = slot; + if (!options->enabled) current = 0; + else if (!selector->guid[0] && !selector->path[0]) current = 1; + else { + current = 1; + for (int i = 0; i < count; ++i) + if (&input->controllers[connected_slots[i]] == selected) current = i + 2; } current += direction < 0 ? -1 : 1; - if (current < 0) { - current = count; - } else if (current > count) { - current = 0; - } + if (current < 0) current = count + 1; + if (current > count + 1) current = 0; if (current == 0) { - memset(selector, 0, sizeof(*selector)); + options->enabled = 0; return; } - i = connected_slots[current - 1]; - snprintf(selector->guid, sizeof(selector->guid), "%s", input->controllers[i].guid); - snprintf(selector->path, sizeof(selector->path), "%s", input->controllers[i].path); - selector->ordinal = ControllerScreen_DeviceOrdinal(input, i); + options->enabled = 1; + memset(selector, 0, sizeof *selector); + if (current == 1) return; /* Automatic */ + const int slot = connected_slots[current - 2]; + snprintf(selector->guid, sizeof selector->guid, "%s", input->controllers[slot].guid); + snprintf(selector->path, sizeof selector->path, "%s", input->controllers[slot].path); + selector->ordinal = ControllerScreen_DeviceOrdinal(input, slot); } -static void ControllerScreen_DeviceText(const AeronControllerSelector* selector, char* text, size_t capacity, +static void ControllerScreen_DeviceText(const XwaControllerOptions* options, char* text, size_t capacity, const AeronControllerSnapshot** selected) { int slot = -1; - const AeronControllerSnapshot* controller = ControllerScreen_Selected(selector, &slot); - + const AeronControllerSelector* selector = &options->device; + const AeronControllerSnapshot* controller = options->enabled ? ControllerScreen_Selected(selector, &slot) : NULL; *selected = controller; - if (!selector->guid[0] && !selector->path[0]) { - if (controller) { - snprintf(text, capacity, "Automatic: %.42s", controller->name); - } else { - snprintf(text, capacity, "%s", "Automatic (none connected)"); - } + if (!options->enabled) snprintf(text, capacity, "%s", "None"); + else if (!selector->guid[0] && !selector->path[0]) { + if (controller) snprintf(text, capacity, "Automatic: %.42s", controller->name); + else snprintf(text, capacity, "%s", "Automatic (none connected)"); } else if (controller) { const AeronInputSnapshot* input = Aeron_InputSnapshot(); int duplicate_count; const int ordinal = ControllerScreen_NameOrdinal(input, slot, &duplicate_count); - if (duplicate_count > 1) { + if (duplicate_count > 1) snprintf(text, capacity, "%.44s #%d", controller->name, ordinal + 1); - } else { - snprintf(text, capacity, "%.48s", controller->name); - } - } else { - snprintf(text, capacity, "Unavailable: %.32s", selector->guid); - } + else snprintf(text, capacity, "%.48s", controller->name); + } else snprintf(text, capacity, "Unavailable: %.32s", selector->guid); } static XwaControllerProfile* ControllerScreen_Profile(XwaModernInputOptions* options, - const AeronControllerSnapshot* controller) { - return controller && controller->kind == AERON_CONTROLLER_KIND_JOYSTICK ? &options->controller.joystick - : &options->controller.gamepad; + const AeronControllerSnapshot* controller) { + XwaControllerOptions* slot = ControllerScreen_Options(options); + return controller && controller->kind == AERON_CONTROLLER_KIND_JOYSTICK + ? &slot->joystick : &slot->gamepad; } static int ControllerScreen_AxisCount(const AeronControllerSnapshot* controller) { @@ -309,25 +318,6 @@ static int ControllerScreen_UpdateDigitalAxisCapture(const AeronControllerSnapsh return 1; } -static int ControllerScreen_PovReleased(const AeronControllerSnapshot* controller) { - int hat; - - if (!controller) { - return 0; - } - if (controller->kind == AERON_CONTROLLER_KIND_GAMEPAD) { - return !(controller->gamepad_buttons & - ((1u << AERON_GAMEPAD_BUTTON_DPAD_UP) | (1u << AERON_GAMEPAD_BUTTON_DPAD_RIGHT) | - (1u << AERON_GAMEPAD_BUTTON_DPAD_DOWN) | (1u << AERON_GAMEPAD_BUTTON_DPAD_LEFT))); - } - for (hat = 0; hat < controller->hat_count; ++hat) { - if (controller->raw_hats[hat] != AERON_CONTROLLER_HAT_CENTERED) { - return 0; - } - } - return 1; -} - static int ControllerScreen_FirstPressedButton(const AeronControllerSnapshot* controller) { uint64_t pressed; int source; @@ -360,9 +350,8 @@ static int ControllerScreen_UpdatePovCapture(XwaModernInputOptions* options, return 0; } if (g_controllerCapture.wait_for_release) { - if (ControllerScreen_PovReleased(controller)) { - g_controllerCapture.wait_for_release = 0; - } + /* Do not wait for EVERY hat to center: maintained switches can stay on. */ + g_controllerCapture.wait_for_release = 0; return 0; } if (controller->kind == AERON_CONTROLLER_KIND_GAMEPAD) { @@ -373,7 +362,8 @@ static int ControllerScreen_UpdatePovCapture(XwaModernInputOptions* options, } } else { for (source = 0; source < controller->hat_count; ++source) { - if (controller->raw_hats[source] != AERON_CONTROLLER_HAT_CENTERED) { + if (controller->raw_hats[source] != AERON_CONTROLLER_HAT_CENTERED && + controller->raw_hats[source] != g_controllerCapture.pov_baseline[source]) { break; } } @@ -408,6 +398,7 @@ void XwaModernControllerOptionsScreen_ResetCapture(void) { g_controllerCapture.button = -1; g_controllerCapture.digital_axis = -1; g_controllerCapture.wait_for_release = 0; + memset(&g_controllerButtonScan, 0, sizeof(g_controllerButtonScan)); } void XwaModernControllerOptionsScreen_Leave(void) { @@ -429,46 +420,46 @@ XwaModernControllerScreenResult XwaModernControllerOptionsScreen_Update(int menu return XWA_MODERN_CONTROLLER_SCREEN_STAY; } XwaModernInputOptions_Get(&options); - ControllerScreen_DeviceText(&options.controller.device, device_text, sizeof(device_text), &selected); + ControllerScreen_DeviceText(ControllerScreen_Options(&options), device_text, sizeof(device_text), &selected); XwaModernOptionsMenu_Begin(&menu, menu_center_x, 110, cursor_row, 9); - XwaModernOptionsMenu_DrawTitle(&menu, "Controller Setup"); + XwaModernOptionsMenu_DrawTitle(&menu, g_controllerSlot ? "Controller 2 Setup" : "Controller 1 Setup"); changed = XwaModernOptionsMenu_DrawValue(&menu, "Active Device", device_text, 100, 0); if (changed) { - ControllerScreen_SelectDevice(&options.controller.device, changed); + ControllerScreen_SelectDevice(ControllerScreen_Options(&options), changed); XwaModernInputOptions_Set(&options); XwaModernInputOptions_Get(&options); - ControllerScreen_DeviceText(&options.controller.device, device_text, sizeof(device_text), &selected); + ControllerScreen_DeviceText(ControllerScreen_Options(&options), device_text, sizeof(device_text), &selected); } changed = XwaModernOptionsMenu_DrawValue(&menu, "Roll Enabled", - toggle_text[options.controller.roll_enabled != 0], 101, 0); + toggle_text[ControllerScreen_Options(&options)->roll_enabled != 0], 101, 0); if (changed) { - options.controller.roll_enabled = !options.controller.roll_enabled; + ControllerScreen_Options(&options)->roll_enabled = !ControllerScreen_Options(&options)->roll_enabled; XwaModernInputOptions_Set(&options); } changed = XwaModernOptionsMenu_DrawValue(&menu, "Rumble Enabled", - toggle_text[options.controller.rumble_enabled != 0], 102, 0); + toggle_text[ControllerScreen_Options(&options)->rumble_enabled != 0], 102, 0); if (changed) { - options.controller.rumble_enabled = !options.controller.rumble_enabled; + ControllerScreen_Options(&options)->rumble_enabled = !ControllerScreen_Options(&options)->rumble_enabled; XwaModernInputOptions_Set(&options); } - snprintf(value, sizeof(value), "%d", options.controller.rumble_strength); + snprintf(value, sizeof(value), "%d", ControllerScreen_Options(&options)->rumble_strength); changed = XwaModernOptionsMenu_DrawValue(&menu, "Rumble Strength", value, 103, 0); if (changed) { - options.controller.rumble_strength += changed; - if (options.controller.rumble_strength < XWA_CONTROLLER_RUMBLE_STRENGTH_MIN) { - options.controller.rumble_strength = XWA_CONTROLLER_RUMBLE_STRENGTH_MAX; - } else if (options.controller.rumble_strength > XWA_CONTROLLER_RUMBLE_STRENGTH_MAX) { - options.controller.rumble_strength = XWA_CONTROLLER_RUMBLE_STRENGTH_MIN; + ControllerScreen_Options(&options)->rumble_strength += changed; + if (ControllerScreen_Options(&options)->rumble_strength < XWA_CONTROLLER_RUMBLE_STRENGTH_MIN) { + ControllerScreen_Options(&options)->rumble_strength = XWA_CONTROLLER_RUMBLE_STRENGTH_MAX; + } else if (ControllerScreen_Options(&options)->rumble_strength > XWA_CONTROLLER_RUMBLE_STRENGTH_MAX) { + ControllerScreen_Options(&options)->rumble_strength = XWA_CONTROLLER_RUMBLE_STRENGTH_MIN; } XwaModernInputOptions_Set(&options); } pressed = XwaModernOptionsMenu_DrawAction( - &menu, "Test Rumble", 104, !selected || !selected->has_rumble || !options.controller.rumble_enabled); + &menu, "Test Rumble", 104, !selected || !selected->has_rumble || !ControllerScreen_Options(&options)->rumble_enabled); if (pressed) { const uint16_t magnitude = - (uint16_t)(options.controller.rumble_strength * 65535u / XWA_CONTROLLER_RUMBLE_STRENGTH_MAX); - XwaControllerMapping_Rumble(magnitude, magnitude, 300); + (uint16_t)(ControllerScreen_Options(&options)->rumble_strength * 65535u / XWA_CONTROLLER_RUMBLE_STRENGTH_MAX); + XwaControllerMapping_RumbleSlot(g_controllerSlot, magnitude, magnitude, 300); } if (XwaModernOptionsMenu_DrawAction(&menu, "Configure Axes", 105, 0)) { XwaControllerMapping_Rumble(0, 0, 0); @@ -479,7 +470,7 @@ XwaModernControllerScreenResult XwaModernControllerOptionsScreen_Update(int menu return XWA_MODERN_CONTROLLER_SCREEN_BUTTONS; } if (XwaModernOptionsMenu_DrawAction(&menu, "Restore Controller Defaults", 107, 0)) { - XwaModernInputOptions_RestoreControllerDefaults(); + XwaModernInputOptions_RestoreControllerSlotDefaults(g_controllerSlot); } pressed = XwaModernOptionsMenu_DrawAction(&menu, FrontendString_Get(STR_BACK), 108, 0); pressed |= XwaModernOptionsMenu_TakeEscape(&menu); @@ -492,13 +483,18 @@ XwaModernControllerScreenResult XwaModernControllerOptionsScreen_Update(int menu } int XwaModernControllerAxesScreen_Update(int menu_center_x, int* cursor_row) { - static const char* const logical_names[] = { "Yaw", "Pitch", "Throttle", "Roll" }; + static const char* const logical_names[] = { "Yaw", "Pitch", "Throttle", "Roll", "Hyperdrive" }; static const char* const toggle_text[] = { "No", "Yes" }; XwaModernInputOptions options; XwaModernOptionsMenu menu; const AeronControllerSnapshot* selected; XwaControllerProfile* profile; char value[96]; + char title[64]; + const int page_count = (XWA_CONTROLLER_LOGICAL_AXIS_COUNT + CONTROLLER_AXIS_PAGE_SIZE - 1) / + CONTROLLER_AXIS_PAGE_SIZE; + int start; + int count; int axis; int changed; int back; @@ -506,23 +502,37 @@ int XwaModernControllerAxesScreen_Update(int menu_center_x, int* cursor_row) { if (!cursor_row) { return 0; } + if (g_controllerAxisPage < 0 || g_controllerAxisPage >= page_count) { + g_controllerAxisPage = 0; + } + start = g_controllerAxisPage * CONTROLLER_AXIS_PAGE_SIZE; + count = XWA_CONTROLLER_LOGICAL_AXIS_COUNT - start; + if (count > CONTROLLER_AXIS_PAGE_SIZE) { + count = CONTROLLER_AXIS_PAGE_SIZE; + } + if (*cursor_row < 0 || *cursor_row >= count * 3 + 2) { + *cursor_row = 0; + } XwaModernInputOptions_Get(&options); - selected = ControllerScreen_Selected(&options.controller.device, NULL); + selected = ControllerScreen_Options(&options)->enabled + ? ControllerScreen_Selected(&ControllerScreen_Options(&options)->device, NULL) : NULL; profile = ControllerScreen_Profile(&options, selected); - XwaModernOptionsMenu_Begin(&menu, menu_center_x, 60, cursor_row, 13); + XwaModernOptionsMenu_Begin(&menu, menu_center_x, 78, cursor_row, count * 3 + 2); if (g_controllerCapture.axis >= 0 && menu.key == XWA_MODERN_MENU_KEY_ESCAPE) { g_controllerCapture.axis = -1; XwaModernOptionsMenu_TakeEscape(&menu); } ControllerScreen_UpdateAxisCapture(&options, selected); - XwaModernOptionsMenu_DrawTitle(&menu, "Controller Axis Mapping"); - for (axis = 0; axis < XWA_CONTROLLER_LOGICAL_AXIS_COUNT; ++axis) { + snprintf(title, sizeof title, "Controller %d Axis Mapping (%d/%d)", + g_controllerSlot + 1, g_controllerAxisPage + 1, page_count); + XwaModernOptionsMenu_DrawTitle(&menu, title); + for (axis = start; axis < start + count; ++axis) { char source_name[48]; const int16_t live = ControllerScreen_Axis(selected, profile->axes[axis].source); ControllerScreen_AxisName(selected, profile->axes[axis].source, source_name, sizeof(source_name)); snprintf(value, sizeof(value), "%s (%+.2f)", source_name, live < 0 ? live / 32768.0 : live / 32767.0); - changed = - XwaModernOptionsMenu_DrawValue(&menu, logical_names[axis], value, 120 + axis * 3, !selected); + changed = XwaModernOptionsMenu_DrawValue( + &menu, logical_names[axis], value, 120 + axis * 3, !selected); if (changed) { ControllerScreen_BeginAxisCapture(selected, axis); } @@ -550,7 +560,7 @@ int XwaModernControllerAxesScreen_Update(int menu_center_x, int* cursor_row) { profile->axes[axis].deadzone = percent / 100.0f; XwaModernInputOptions_Set(&options); } - if (axis + 1 < XWA_CONTROLLER_LOGICAL_AXIS_COUNT) { + if (axis + 1 < start + count) { menu.y += 20; } } @@ -564,10 +574,17 @@ int XwaModernControllerAxesScreen_Update(int menu_center_x, int* cursor_row) { &(FrontendRect) { 0, menu.y, 639, menu.y + 15 }, g_colorGreen); menu.y += 20; } - back = XwaModernOptionsMenu_DrawAction(&menu, FrontendString_Get(STR_BACK), 132, 0); + if (XwaModernOptionsMenu_DrawAction(&menu, g_controllerAxisPage ? "Previous Page" : "Next Page", 145, 0)) { + g_controllerCapture.axis = -1; + g_controllerAxisPage = (g_controllerAxisPage + 1) % page_count; + *cursor_row = 0; + return 0; + } + back = XwaModernOptionsMenu_DrawAction(&menu, FrontendString_Get(STR_BACK), 150, 0); back |= XwaModernOptionsMenu_TakeEscape(&menu); if (back) { g_controllerCapture.axis = -1; + g_controllerAxisPage = 0; return 1; } return 0; @@ -597,9 +614,9 @@ static void ControllerScreen_DigitalSourceName(const AeronControllerSnapshot* co } static XwaControllerProfile* ControllerScreen_ProfileForKind(XwaModernInputOptions* options, - AeronControllerKind kind) { - return kind == AERON_CONTROLLER_KIND_JOYSTICK ? &options->controller.joystick - : &options->controller.gamepad; + AeronControllerKind kind) { + XwaControllerOptions* slot = ControllerScreen_Options(options); + return kind == AERON_CONTROLLER_KIND_JOYSTICK ? &slot->joystick : &slot->gamepad; } static int ControllerScreen_FindButton(const XwaControllerProfile* profile, @@ -882,8 +899,34 @@ static void ControllerScreen_SelectPressedBinding(const AeronControllerSnapshot* g_controllerCapture.digital_axis >= 0) { return; } + /* On entry, baseline held axes and hats. Their levels must not select + * mappings until they cross a threshold or change direction again. */ + if (!g_controllerButtonScan.ready || g_controllerButtonScan.instance_id != controller->instance_id) { + g_controllerButtonScan.ready = 1; + g_controllerButtonScan.instance_id = controller->instance_id; + for (int a = 0; a < ControllerScreen_AxisCount(controller) && a < AERON_CONTROLLER_AXIS_MAX; ++a) + g_controllerButtonScan.axes[a] = ControllerScreen_Axis(controller, a); + memcpy(g_controllerButtonScan.hats, controller->raw_hats, sizeof(g_controllerButtonScan.hats)); + g_controllerButtonScan.raw_buttons = controller->raw_buttons; + g_controllerButtonScan.gamepad_buttons = controller->gamepad_buttons; + return; + } source = ControllerScreen_FirstPressedButton(controller); + /* Compare held levels as a fallback if an SDL press pulse was missed. */ + if (source < 0) { + const uint64_t rising = controller->kind == AERON_CONTROLLER_KIND_GAMEPAD + ? (uint64_t)(controller->gamepad_buttons & ~g_controllerButtonScan.gamepad_buttons) + : controller->raw_buttons & ~g_controllerButtonScan.raw_buttons; + const int count = controller->kind == AERON_CONTROLLER_KIND_GAMEPAD + ? AERON_GAMEPAD_BUTTON_COUNT : controller->button_count; + for (int b = 0; b < count; ++b) + if (rising & (UINT64_C(1) << b)) { source = b; break; } + } direction = ControllerScreen_PressedPovDirection(controller, profile); + if (controller->kind == AERON_CONTROLLER_KIND_JOYSTICK && profile->pov_source >= 0 && + profile->pov_source < controller->hat_count && + controller->raw_hats[profile->pov_source] == g_controllerButtonScan.hats[profile->pov_source]) + direction = -1; for (index = 0; index < binding_count; ++index) { const ControllerBindingRow* row = &bindings[index]; int active = direction >= 0 && row->pov_direction == direction; @@ -898,14 +941,24 @@ static void ControllerScreen_SelectPressedBinding(const AeronControllerSnapshot* : (value < 0 ? (double)-value / 32768.0 : 0.0); const float threshold = row->logical_button >= 0 ? profile->buttons[row->logical_button].threshold : XWA_CONTROLLER_DIGITAL_THRESHOLD_DEFAULT; - active = magnitude >= threshold; + const int16_t old = row->source >= 0 && row->source < AERON_CONTROLLER_AXIS_MAX + ? g_controllerButtonScan.axes[row->source] : 0; + const double old_magnitude = row->kind == AERON_CONTROLLER_DIGITAL_AXIS_POSITIVE + ? (old > 0 ? (double)old / 32767.0 : 0.0) + : (old < 0 ? (double)-old / 32768.0 : 0.0); + active = magnitude >= threshold && old_magnitude < threshold; } if (active) { g_controllerButtonPage = index / CONTROLLER_PAGE_SIZE; *cursor_row = 1 + index % CONTROLLER_PAGE_SIZE; - return; + break; } } + for (int a = 0; a < ControllerScreen_AxisCount(controller) && a < AERON_CONTROLLER_AXIS_MAX; ++a) + g_controllerButtonScan.axes[a] = ControllerScreen_Axis(controller, a); + memcpy(g_controllerButtonScan.hats, controller->raw_hats, sizeof(g_controllerButtonScan.hats)); + g_controllerButtonScan.raw_buttons = controller->raw_buttons; + g_controllerButtonScan.gamepad_buttons = controller->gamepad_buttons; } int XwaModernControllerButtonsScreen_Update(int menu_center_x, int* cursor_row) { @@ -930,7 +983,8 @@ int XwaModernControllerButtonsScreen_Update(int menu_center_x, int* cursor_row) return 0; } XwaModernInputOptions_Get(&options); - selected = ControllerScreen_Selected(&options.controller.device, NULL); + selected = ControllerScreen_Options(&options)->enabled + ? ControllerScreen_Selected(&ControllerScreen_Options(&options)->device, NULL) : NULL; profile = ControllerScreen_Profile(&options, selected); if (ControllerScreen_UpdateDigitalAxisCapture(selected, &captured_binding)) { captured_binding.logical_button = @@ -972,7 +1026,7 @@ int XwaModernControllerButtonsScreen_Update(int menu_center_x, int* cursor_row) XwaModernOptionsMenu_TakeEscape(&menu); } ControllerScreen_UpdatePovCapture(&options, selected); - XwaModernOptionsMenu_DrawTitle(&menu, "Controller Button Bindings"); + XwaModernOptionsMenu_DrawTitle(&menu, g_controllerSlot ? "Controller 2 Button Bindings" : "Controller 1 Button Bindings"); if (selected && selected->kind == AERON_CONTROLLER_KIND_GAMEPAD) { snprintf(value, sizeof(value), "%s", profile->pov_source ? "D-pad" : "Not mapped"); @@ -987,6 +1041,11 @@ int XwaModernControllerButtonsScreen_Update(int menu_center_x, int* cursor_row) g_controllerCapture.digital_axis = -1; g_controllerCapture.button = XWA_CONTROLLER_LOGICAL_BUTTON_COUNT; g_controllerCapture.wait_for_release = 1; + memset(g_controllerCapture.pov_baseline, 0, sizeof(g_controllerCapture.pov_baseline)); + if (selected && selected->kind == AERON_CONTROLLER_KIND_JOYSTICK) { + memcpy(g_controllerCapture.pov_baseline, selected->raw_hats, + sizeof(g_controllerCapture.pov_baseline)); + } } if (selected && XwaModernOptionsMenu_LastRowSelected(&menu) && Keyboard_IsKeyDown(CONTROLLER_KEY_DELETE)) { diff --git a/src/xwa_runtime/config/modern_controller_options_screen.h b/src/xwa_runtime/config/modern_controller_options_screen.h index d3cf4145..20e03b20 100644 --- a/src/xwa_runtime/config/modern_controller_options_screen.h +++ b/src/xwa_runtime/config/modern_controller_options_screen.h @@ -12,6 +12,7 @@ typedef enum XwaModernControllerScreenResult { XWA_MODERN_CONTROLLER_SCREEN_BUTTONS, } XwaModernControllerScreenResult; +void XwaModernControllerOptionsScreen_SelectSlot(int slot); XwaModernControllerScreenResult XwaModernControllerOptionsScreen_Update(int menu_center_x, int* cursor_row); int XwaModernControllerAxesScreen_Update(int menu_center_x, int* cursor_row); int XwaModernControllerButtonsScreen_Update(int menu_center_x, int* cursor_row); diff --git a/src/xwa_runtime/config/modern_head_tracking_screen.c b/src/xwa_runtime/config/modern_head_tracking_screen.c new file mode 100644 index 00000000..a3674a03 --- /dev/null +++ b/src/xwa_runtime/config/modern_head_tracking_screen.c @@ -0,0 +1,83 @@ +#include "xwa_runtime/config/modern_head_tracking_screen.h" + +#include "xwa/assets/string_table.h" +#include "xwa_runtime/config/modern_input_options.h" +#include "xwa_runtime/config/modern_options_menu.h" +#include "xwa_runtime/input/head_tracking.h" + +#include + +int XwaModernHeadTrackingScreen_Update(int menu_center_x, int* cursor_row) { + /* One master switch, one source, six inversion controls, and Back. */ + enum { row_count = 9 }; + static const char* const toggle_texts[] = { "Off", "On" }; +#ifdef _WIN32 + static const char* const source_texts[] = { "TrackIR", "OpenTrack UDP" }; +#else + static const char* const source_texts[] = { "OpenTrack UDP" }; +#endif + static const char* const invert_labels[XWA_HEAD_TRACK_AXIS_COUNT] = { "Invert Yaw", "Invert Pitch", + "Invert Roll", "Invert X", + "Invert Y", "Invert Z" }; + XwaModernInputOptions options; + XwaModernOptionsMenu menu; + uint8_t enabled; + uint8_t source; + uint8_t invert[XWA_HEAD_TRACK_AXIS_COUNT]; + int changed = 0; + int previous_enabled; + int axis; + int back; + + if (!cursor_row) + return 0; + XwaModernInputOptions_Get(&options); + enabled = (uint8_t)(options.head_tracking.enabled != 0); + previous_enabled = enabled; +#ifdef _WIN32 + source = (uint8_t)options.head_tracking.source; +#else + /* Native NPClient is Windows-only; the one visible choice is UDP. */ + source = 0; +#endif + for (axis = 0; axis < XWA_HEAD_TRACK_AXIS_COUNT; ++axis) { + invert[axis] = (uint8_t)(options.head_tracking.invert[axis] != 0); + } + XwaModernOptionsMenu_Begin(&menu, menu_center_x, XwaModernOptionsMenu_CenteredY(row_count), cursor_row, + row_count); + XwaModernOptionsMenu_DrawTitle(&menu, "Head Tracking Setup"); + + changed |= + XwaModernOptionsMenu_DrawCycleU8(&menu, &enabled, "Head Tracking Enabled", toggle_texts, 2, 160, 0); + changed |= XwaModernOptionsMenu_DrawCycleU8(&menu, &source, "Head Tracking Source", source_texts, + (int)(sizeof(source_texts) / sizeof(source_texts[0])), 161, + !enabled); + for (axis = 0; axis < XWA_HEAD_TRACK_AXIS_COUNT; ++axis) { + changed |= XwaModernOptionsMenu_DrawCycleU8(&menu, &invert[axis], invert_labels[axis], toggle_texts, + 2, 162 + axis, !enabled); + } + if (changed) { + options.head_tracking.enabled = enabled != 0; +#ifdef _WIN32 + options.head_tracking.source = source; +#else + options.head_tracking.source = XWA_HEAD_TRACK_SOURCE_OPENTRACK_UDP; +#endif + for (axis = 0; axis < XWA_HEAD_TRACK_AXIS_COUNT; ++axis) { + options.head_tracking.invert[axis] = invert[axis] != 0; + } + if (XwaModernInputOptions_Set(&options) && previous_enabled && !enabled) { + /* Disconnect immediately, including when settings are changed + * outside a mission and no subsequent camera poll is expected. */ + XwaHeadTracking_Shutdown(); + } + } + + back = XwaModernOptionsMenu_DrawAction(&menu, FrontendString_Get(STR_BACK), 168, 0); + back |= XwaModernOptionsMenu_TakeEscape(&menu); + if (back) { + XwaModernInputOptions_Flush(); + return 1; + } + return 0; +} diff --git a/src/xwa_runtime/config/modern_head_tracking_screen.h b/src/xwa_runtime/config/modern_head_tracking_screen.h new file mode 100644 index 00000000..566d83d6 --- /dev/null +++ b/src/xwa_runtime/config/modern_head_tracking_screen.h @@ -0,0 +1,15 @@ +#ifndef XWA_RUNTIME_MODERN_HEAD_TRACKING_SCREEN_H +#define XWA_RUNTIME_MODERN_HEAD_TRACKING_SCREEN_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* Returns 1 on Back/Escape, 0 while the head-tracking page remains open. */ +int XwaModernHeadTrackingScreen_Update(int menu_center_x, int* cursor_row); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/xwa_runtime/config/modern_input_options.c b/src/xwa_runtime/config/modern_input_options.c index da25cae8..b0fea9ae 100644 --- a/src/xwa_runtime/config/modern_input_options.c +++ b/src/xwa_runtime/config/modern_input_options.c @@ -102,74 +102,89 @@ static int XwaControllerGamepadPovIsUnambiguous(const XwaControllerProfile* prof return 1; } -int XwaModernInputOptions_Validate(const XwaModernInputOptions* options) { +static int XwaControllerOptions_Validate(const XwaControllerOptions* controller) { size_t guid_length; size_t i; - if (!options || options->mouse_sensitivity < XWA_MODERN_MOUSE_SENSITIVITY_MIN || - options->mouse_sensitivity > XWA_MODERN_MOUSE_SENSITIVITY_MAX || - options->mouse_mode < XWA_MODERN_MOUSE_MODE_POSITION || - options->mouse_mode > XWA_MODERN_MOUSE_MODE_RATE || - options->controller.rumble_strength < XWA_CONTROLLER_RUMBLE_STRENGTH_MIN || - options->controller.rumble_strength > XWA_CONTROLLER_RUMBLE_STRENGTH_MAX || - options->controller.device.ordinal < 0 || - options->controller.device.ordinal > XWA_CONTROLLER_DEVICE_ORDINAL_MAX || - !memchr(options->controller.device.guid, '\0', sizeof(options->controller.device.guid)) || - !memchr(options->controller.device.path, '\0', sizeof(options->controller.device.path))) { + if (!controller || controller->rumble_strength < XWA_CONTROLLER_RUMBLE_STRENGTH_MIN || + controller->rumble_strength > XWA_CONTROLLER_RUMBLE_STRENGTH_MAX || + controller->device.ordinal < 0 || + controller->device.ordinal > XWA_CONTROLLER_DEVICE_ORDINAL_MAX || + !memchr(controller->device.guid, '\0', sizeof(controller->device.guid)) || + !memchr(controller->device.path, '\0', sizeof(controller->device.path))) { return 0; } - guid_length = strlen(options->controller.device.guid); + guid_length = strlen(controller->device.guid); if (guid_length != 0 && guid_length != 32) { return 0; } if (guid_length == 0 && - (options->controller.device.path[0] != '\0' || options->controller.device.ordinal != 0)) { + (controller->device.path[0] != '\0' || controller->device.ordinal != 0)) { return 0; } for (i = 0; i < guid_length; ++i) { - if (!isxdigit((unsigned char)options->controller.device.guid[i])) { + if (!isxdigit((unsigned char)controller->device.guid[i])) { return 0; } } - return XwaControllerProfile_IsValid(&options->controller.gamepad, AERON_GAMEPAD_AXIS_COUNT, + return XwaControllerProfile_IsValid(&controller->gamepad, AERON_GAMEPAD_AXIS_COUNT, AERON_GAMEPAD_BUTTON_COUNT, 0, 1) && - XwaControllerGamepadPovIsUnambiguous(&options->controller.gamepad) && - XwaControllerProfile_IsValid(&options->controller.joystick, AERON_CONTROLLER_AXIS_MAX, + XwaControllerGamepadPovIsUnambiguous(&controller->gamepad) && + XwaControllerProfile_IsValid(&controller->joystick, AERON_CONTROLLER_AXIS_MAX, AERON_CONTROLLER_BUTTON_MAX, -1, AERON_CONTROLLER_HAT_MAX - 1); } -static void XwaModernInputOptions_Normalize(XwaModernInputOptions* options) { - int i; - options->mouse_flight_enabled = options->mouse_flight_enabled != 0; - options->mouse_invert_y = options->mouse_invert_y != 0; - options->controller.roll_enabled = options->controller.roll_enabled != 0; - options->controller.rumble_enabled = options->controller.rumble_enabled != 0; - options->controller.gamepad.pov_source = options->controller.gamepad.pov_source != 0; +int XwaModernInputOptions_Validate(const XwaModernInputOptions* options) { + return options && options->mouse_sensitivity >= XWA_MODERN_MOUSE_SENSITIVITY_MIN && + options->mouse_sensitivity <= XWA_MODERN_MOUSE_SENSITIVITY_MAX && + options->mouse_mode >= XWA_MODERN_MOUSE_MODE_POSITION && + options->mouse_mode <= XWA_MODERN_MOUSE_MODE_RATE && + (options->head_tracking.source == XWA_HEAD_TRACK_SOURCE_TRACKIR || + options->head_tracking.source == XWA_HEAD_TRACK_SOURCE_OPENTRACK_UDP) && + XwaControllerOptions_Validate(&options->controller) && + XwaControllerOptions_Validate(&options->controller2); +} + +static void XwaControllerOptions_Normalize(XwaControllerOptions* controller) { + int i; + controller->enabled = controller->enabled != 0; + controller->roll_enabled = controller->roll_enabled != 0; + controller->rumble_enabled = controller->rumble_enabled != 0; + controller->gamepad.pov_source = controller->gamepad.pov_source != 0; for (i = 0; i < XWA_CONTROLLER_LOGICAL_AXIS_COUNT; ++i) { - options->controller.gamepad.axes[i].invert = options->controller.gamepad.axes[i].invert != 0; - options->controller.joystick.axes[i].invert = options->controller.joystick.axes[i].invert != 0; + controller->gamepad.axes[i].invert = controller->gamepad.axes[i].invert != 0; + controller->joystick.axes[i].invert = controller->joystick.axes[i].invert != 0; } for (i = 0; i < XWA_CONTROLLER_LOGICAL_BUTTON_COUNT; ++i) { - AeronControllerDigitalSource* gamepad = &options->controller.gamepad.buttons[i]; - AeronControllerDigitalSource* joystick = &options->controller.joystick.buttons[i]; - if (gamepad->kind == AERON_CONTROLLER_DIGITAL_NONE) { - gamepad->index = 0; - } - if (joystick->kind == AERON_CONTROLLER_DIGITAL_NONE) { - joystick->index = 0; - } + AeronControllerDigitalSource* gamepad = &controller->gamepad.buttons[i]; + AeronControllerDigitalSource* joystick = &controller->joystick.buttons[i]; + if (gamepad->kind == AERON_CONTROLLER_DIGITAL_NONE) gamepad->index = 0; + if (joystick->kind == AERON_CONTROLLER_DIGITAL_NONE) joystick->index = 0; if (gamepad->kind == AERON_CONTROLLER_DIGITAL_NONE || - gamepad->kind == AERON_CONTROLLER_DIGITAL_BUTTON) { + gamepad->kind == AERON_CONTROLLER_DIGITAL_BUTTON) gamepad->threshold = XWA_CONTROLLER_DIGITAL_THRESHOLD_DEFAULT; - } if (joystick->kind == AERON_CONTROLLER_DIGITAL_NONE || - joystick->kind == AERON_CONTROLLER_DIGITAL_BUTTON) { + joystick->kind == AERON_CONTROLLER_DIGITAL_BUTTON) joystick->threshold = XWA_CONTROLLER_DIGITAL_THRESHOLD_DEFAULT; - } } } +static void XwaModernInputOptions_Normalize(XwaModernInputOptions* options) { + int i; + options->mouse_flight_enabled = options->mouse_flight_enabled != 0; + options->mouse_invert_y = options->mouse_invert_y != 0; + options->head_tracking.enabled = options->head_tracking.enabled != 0; +#ifndef _WIN32 + /* Prefer the cross-platform UDP head tracker outside Windows. */ + options->head_tracking.source = XWA_HEAD_TRACK_SOURCE_OPENTRACK_UDP; +#endif + for (i = 0; i < XWA_HEAD_TRACK_AXIS_COUNT; ++i) + options->head_tracking.invert[i] = options->head_tracking.invert[i] != 0; + XwaControllerOptions_Normalize(&options->controller); + XwaControllerOptions_Normalize(&options->controller2); +} + static int XwaControllerProfile_AreEqual(const XwaControllerProfile* lhs, const XwaControllerProfile* rhs) { int i; @@ -192,7 +207,7 @@ static int XwaControllerProfile_AreEqual(const XwaControllerProfile* lhs, const } static int XwaControllerOptions_AreEqual(const XwaControllerOptions* lhs, const XwaControllerOptions* rhs) { - return strcmp(lhs->device.guid, rhs->device.guid) == 0 && + return lhs->enabled == rhs->enabled && strcmp(lhs->device.guid, rhs->device.guid) == 0 && strcmp(lhs->device.path, rhs->device.path) == 0 && lhs->device.ordinal == rhs->device.ordinal && lhs->roll_enabled == rhs->roll_enabled && lhs->rumble_enabled == rhs->rumble_enabled && lhs->rumble_strength == rhs->rumble_strength && @@ -204,7 +219,12 @@ static int XwaModernInputOptions_AreEqual(const XwaModernInputOptions* lhs, const XwaModernInputOptions* rhs) { return lhs->mouse_flight_enabled == rhs->mouse_flight_enabled && lhs->mouse_mode == rhs->mouse_mode && lhs->mouse_sensitivity == rhs->mouse_sensitivity && lhs->mouse_invert_y == rhs->mouse_invert_y && - XwaControllerOptions_AreEqual(&lhs->controller, &rhs->controller); + lhs->head_tracking.enabled == rhs->head_tracking.enabled && + lhs->head_tracking.source == rhs->head_tracking.source && + memcmp(lhs->head_tracking.invert, rhs->head_tracking.invert, sizeof(lhs->head_tracking.invert)) == + 0 && + XwaControllerOptions_AreEqual(&lhs->controller, &rhs->controller) && + XwaControllerOptions_AreEqual(&lhs->controller2, &rhs->controller2); } void XwaModernInputOptions_Configure(const XwaModernInputOptions* defaults, @@ -257,6 +277,15 @@ int XwaModernInputOptions_Set(const XwaModernInputOptions* options) { return 1; } +int XwaModernInputOptions_RestoreControllerSlotDefaults(int slot) { + XwaModernInputOptions options; + if (!g_modernInputOptions.configured || slot < 0 || slot > 1) return 0; + options = g_modernInputOptions.options; + if (slot == 0) options.controller = g_modernInputOptions.defaults.controller; + else options.controller2 = g_modernInputOptions.defaults.controller2; + return XwaModernInputOptions_Set(&options); +} + int XwaModernInputOptions_RestoreControllerDefaults(void) { XwaModernInputOptions options; diff --git a/src/xwa_runtime/config/modern_input_options.h b/src/xwa_runtime/config/modern_input_options.h index 5ab25687..ee50cd82 100644 --- a/src/xwa_runtime/config/modern_input_options.h +++ b/src/xwa_runtime/config/modern_input_options.h @@ -15,9 +15,9 @@ extern "C" { enum { XWA_MODERN_MOUSE_SENSITIVITY_MIN = 1, XWA_MODERN_MOUSE_SENSITIVITY_MAX = 9, - XWA_CONTROLLER_LOGICAL_AXIS_COUNT = 4, - XWA_CONTROLLER_LOGICAL_BUTTON_COUNT = 16, - XWA_CONTROLLER_ACTION_COUNT = 20, + XWA_CONTROLLER_LOGICAL_AXIS_COUNT = 5, + XWA_CONTROLLER_LOGICAL_BUTTON_COUNT = 64, + XWA_CONTROLLER_ACTION_COUNT = XWA_CONTROLLER_LOGICAL_BUTTON_COUNT + 4, XWA_CONTROLLER_RUMBLE_STRENGTH_MIN = 0, XWA_CONTROLLER_RUMBLE_STRENGTH_MAX = 8, XWA_CONTROLLER_DEVICE_ORDINAL_MAX = 15, @@ -36,6 +36,7 @@ typedef enum XwaControllerLogicalAxis { XWA_CONTROLLER_AXIS_PITCH, XWA_CONTROLLER_AXIS_THROTTLE, XWA_CONTROLLER_AXIS_ROLL, + XWA_CONTROLLER_AXIS_HYPERDRIVE, } XwaControllerLogicalAxis; typedef struct XwaControllerAxisBinding { @@ -59,6 +60,8 @@ typedef struct XwaControllerProfile { } XwaControllerProfile; typedef struct XwaControllerOptions { + /* An empty device GUID means Automatic only while enabled. */ + int enabled; AeronControllerSelector device; int roll_enabled; int rumble_enabled; @@ -67,6 +70,26 @@ typedef struct XwaControllerOptions { XwaControllerProfile joystick; } XwaControllerOptions; +enum { + XWA_HEAD_TRACK_SOURCE_TRACKIR = 0, + XWA_HEAD_TRACK_SOURCE_OPENTRACK_UDP = 1, + XWA_HEAD_TRACK_AXIS_YAW = 0, + XWA_HEAD_TRACK_AXIS_PITCH, + XWA_HEAD_TRACK_AXIS_ROLL, + XWA_HEAD_TRACK_AXIS_X, + XWA_HEAD_TRACK_AXIS_Y, + XWA_HEAD_TRACK_AXIS_Z, + XWA_HEAD_TRACK_AXIS_COUNT +}; + +typedef struct XwaHeadTrackingOptions { + int enabled; + /* Enumeration deliberately kept separate from the source ABIs, + * so future head-tracking providers can share the same menu. */ + int source; + int invert[XWA_HEAD_TRACK_AXIS_COUNT]; +} XwaHeadTrackingOptions; + typedef struct XwaModernInputOptions { int mouse_flight_enabled; XwaModernMouseMode mouse_mode; @@ -76,6 +99,8 @@ typedef struct XwaModernInputOptions { int mouse_sensitivity; int mouse_invert_y; XwaControllerOptions controller; + XwaControllerOptions controller2; + XwaHeadTrackingOptions head_tracking; } XwaModernInputOptions; typedef void (*XwaModernInputOptionsApplyFn)(const XwaModernInputOptions* options); @@ -90,6 +115,7 @@ void XwaModernInputOptions_Get(XwaModernInputOptions* out); void XwaModernInputOptions_GetDefaults(XwaModernInputOptions* out); int XwaModernInputOptions_Set(const XwaModernInputOptions* options); int XwaModernInputOptions_RestoreControllerDefaults(void); +int XwaModernInputOptions_RestoreControllerSlotDefaults(int slot); int XwaModernInputOptions_Flush(void); int XwaModernInputOptions_IsDirty(void); diff --git a/src/xwa_runtime/config/modern_input_options_screen.c b/src/xwa_runtime/config/modern_input_options_screen.c index 700bc00d..6d0a1850 100644 --- a/src/xwa_runtime/config/modern_input_options_screen.c +++ b/src/xwa_runtime/config/modern_input_options_screen.c @@ -33,7 +33,7 @@ int XwaModernInputOptionsScreen_Update(int menu_center_x, int* cursor_row) { sensitivity = (uint8_t)(options.mouse_sensitivity - XWA_MODERN_MOUSE_SENSITIVITY_MIN); invert_y = (uint8_t)(options.mouse_invert_y != 0); changed = 0; - XwaModernOptionsMenu_Begin(&menu, menu_center_x, 160, cursor_row, 6); + XwaModernOptionsMenu_Begin(&menu, menu_center_x, 135, cursor_row, 8); XwaModernOptionsMenu_DrawTitle(&menu, FrontendString_Get(STR_CONFIG_GAME_CONTROLLER_OPTIONS)); changed |= XwaModernOptionsMenu_DrawCycleU8(&menu, &mouse_flight, "Mouse Flight Control", toggle_texts, 2, @@ -53,9 +53,15 @@ int XwaModernInputOptionsScreen_Update(int menu_center_x, int* cursor_row) { } result = 0; - if (XwaModernOptionsMenu_DrawAction(&menu, "Controller Setup", 75, 0)) { + if (XwaModernOptionsMenu_DrawAction(&menu, "Controller 1 Setup", 75, 0)) { result = 2; } + if (XwaModernOptionsMenu_DrawAction(&menu, "Controller 2 Setup", 77, 0)) { + result = 4; + } + if (XwaModernOptionsMenu_DrawAction(&menu, "Head Tracking Setup", 76, 0)) { + result = 3; + } pressed = XwaModernOptionsMenu_DrawAction(&menu, FrontendString_Get(STR_BACK), 73, 0); pressed |= XwaModernOptionsMenu_TakeEscape(&menu); if (pressed && result == 0) { diff --git a/src/xwa_runtime/config/modern_video_options.c b/src/xwa_runtime/config/modern_video_options.c index 3fca4f48..d0bf2b49 100644 --- a/src/xwa_runtime/config/modern_video_options.c +++ b/src/xwa_runtime/config/modern_video_options.c @@ -30,7 +30,8 @@ static int XwaModernVideoOptions_IsValid(const XwaModernVideoOptions* options) { options->sdr_gamma <= XWA_MODERN_SDR_GAMMA_SRGB && options->paper_white >= XWA_MODERN_PAPER_WHITE_AUTO && options->paper_white <= XWA_MODERN_PAPER_WHITE_400 && isfinite(options->hud_scale) && - options->hud_scale >= XWA_MODERN_HUD_SCALE_MIN && options->hud_scale <= XWA_MODERN_HUD_SCALE_MAX; + options->hud_scale >= XWA_MODERN_HUD_SCALE_MIN && options->hud_scale <= XWA_MODERN_HUD_SCALE_MAX && + (options->smooth_menu_fonts == 0 || options->smooth_menu_fonts == 1); } static int XwaModernVideoOptions_AreEqual(const XwaModernVideoOptions* lhs, @@ -40,7 +41,7 @@ static int XwaModernVideoOptions_AreEqual(const XwaModernVideoOptions* lhs, lhs->msaa == rhs->msaa && lhs->motion_blur_quality == rhs->motion_blur_quality && lhs->motion_blur_amount == rhs->motion_blur_amount && lhs->hdr_output == rhs->hdr_output && lhs->sdr_gamma == rhs->sdr_gamma && lhs->paper_white == rhs->paper_white && - lhs->hud_scale == rhs->hud_scale; + lhs->hud_scale == rhs->hud_scale && lhs->smooth_menu_fonts == rhs->smooth_menu_fonts; } void XwaModernVideoOptions_Configure(const XwaModernVideoOptions* options, XwaModernVideoOptionsApplyFn apply, diff --git a/src/xwa_runtime/config/modern_video_options.h b/src/xwa_runtime/config/modern_video_options.h index d4b9c6ea..e02f041e 100644 --- a/src/xwa_runtime/config/modern_video_options.h +++ b/src/xwa_runtime/config/modern_video_options.h @@ -81,6 +81,7 @@ typedef struct XwaModernVideoOptions { XWA_MODERN_HUD_SCALE_MAX. At 1, the HUD has the size that the stock game gives it at most resolutions, in any window. */ float hud_scale; + int smooth_menu_fonts; /* original frontend bitmap fonts only */ } XwaModernVideoOptions; #define XWA_MODERN_HUD_SCALE_MIN 0.5f @@ -98,6 +99,7 @@ enum { XWA_MODERN_VIDEO_OVERRIDE_MOTION_BLUR_AMOUNT = 1u << 8, XWA_MODERN_VIDEO_OVERRIDE_SHADOW_QUALITY = 1u << 9, XWA_MODERN_VIDEO_OVERRIDE_HUD_SCALE = 1u << 10, + XWA_MODERN_VIDEO_OVERRIDE_SMOOTH_MENU_FONTS = 1u << 11, }; typedef void (*XwaModernVideoOptionsApplyFn)(const XwaModernVideoOptions* options); diff --git a/src/xwa_runtime/config/modern_video_options_screen.c b/src/xwa_runtime/config/modern_video_options_screen.c index 32596189..4a0f6f2a 100644 --- a/src/xwa_runtime/config/modern_video_options_screen.c +++ b/src/xwa_runtime/config/modern_video_options_screen.c @@ -13,7 +13,7 @@ int XwaModernVideoOptionsScreen_Update(int menu_center_x, int* cursor_row) { /* The rows under the title, Back included. */ - enum { row_count = 15 }; + enum { row_count = 16 }; static const char* const window_mode_texts[] = { "Windowed", "Fullscreen" }; static const char* const quality_texts[] = { "Off", "Low", "High" }; static const char* const shadow_quality_texts[] = { "Standard", "High" }; @@ -53,6 +53,7 @@ int XwaModernVideoOptionsScreen_Update(int menu_center_x, int* cursor_row) { uint8_t paper_white; uint8_t hud_scale; uint8_t original_hud_scale; + uint8_t smooth_menu_fonts; uint8_t enhanced_camera; uint8_t camera_shake; uint8_t target_readout; @@ -85,6 +86,7 @@ int XwaModernVideoOptionsScreen_Update(int menu_center_x, int* cursor_row) { hud_scale = i; } original_hud_scale = hud_scale; + smooth_menu_fonts = (uint8_t)options.smooth_menu_fonts; XwaModernCameraOptions_Get(&camera_options); enhanced_camera = (uint8_t)(camera_options.enabled != 0); XwaModernCameraShakeOptions_Get(&camera_shake_options); @@ -103,6 +105,8 @@ int XwaModernVideoOptionsScreen_Update(int menu_center_x, int* cursor_row) { shadow_quality_texts, 2, 62, 0); changed |= XwaModernOptionsMenu_DrawCycleU8(&menu, &fsr, "FSR Upscaling", fsr_texts, 5, 63, 0); changed |= XwaModernOptionsMenu_DrawCycleU8(&menu, &msaa, "MSAA", msaa_texts, 4, 64, 0); + changed |= XwaModernOptionsMenu_DrawCycleU8(&menu, &smooth_menu_fonts, "Smooth Menu Fonts", toggle_texts, + 2, 72, 0); changed |= XwaModernOptionsMenu_DrawCycleU8(&menu, &motion_blur_quality, "Motion Blur", motion_blur_texts, 3, 65, 0); motion_blur_amount_changed = @@ -154,6 +158,7 @@ int XwaModernVideoOptionsScreen_Update(int menu_center_x, int* cursor_row) { if (hud_scale != original_hud_scale) { options.hud_scale = hud_scale_values[hud_scale]; } + options.smooth_menu_fonts = smooth_menu_fonts; XwaModernVideoOptions_Set(&options); } /* The exterior camera lag and tilt, the cockpit sway and the exterior HUD. diff --git a/src/xwa_runtime/input/controller_mapping.c b/src/xwa_runtime/input/controller_mapping.c index e9f5acd1..b206dbd5 100644 --- a/src/xwa_runtime/input/controller_mapping.c +++ b/src/xwa_runtime/input/controller_mapping.c @@ -1,4 +1,5 @@ #include "xwa_runtime/input/controller_mapping.h" +#include "xwa_runtime/input/hyperdrive_axis.h" #include "aeron/aeron.h" @@ -12,8 +13,16 @@ enum { typedef struct XwaControllerMappingState { XwaControllerOptions options; int configured; + XwaControllerOptions secondary; + int secondary_configured; uint32_t previous_instance_id; - uint32_t digital_axis_buttons; + uint32_t previous_secondary_id; + uint64_t digital_axis_buttons[2]; + uint64_t pending_buttons[2]; + int previous_pov[2]; + uint32_t actions_instance_id[2]; + uint32_t hyperdrive_instance_id[2]; + int hyperdrive_armed[2]; } XwaControllerMappingState; static XwaControllerMappingState g_controllerMapping; @@ -40,20 +49,35 @@ static int ControllerMapping_ProfileEqual(const XwaControllerProfile* lhs, const } static int ControllerMapping_OptionsEqual(const XwaControllerOptions* lhs, const XwaControllerOptions* rhs) { - return strcmp(lhs->device.guid, rhs->device.guid) == 0 && + return lhs->enabled == rhs->enabled && strcmp(lhs->device.guid, rhs->device.guid) == 0 && strcmp(lhs->device.path, rhs->device.path) == 0 && lhs->device.ordinal == rhs->device.ordinal && lhs->rumble_enabled == rhs->rumble_enabled && ControllerMapping_ProfileEqual(&lhs->gamepad, &rhs->gamepad) && ControllerMapping_ProfileEqual(&lhs->joystick, &rhs->joystick); } -const AeronControllerSnapshot* XwaControllerMapping_SelectedController(void) { +const AeronControllerSnapshot* XwaControllerMapping_ControllerForSlot(int slot) { const AeronInputSnapshot* input = Aeron_InputSnapshot(); + const XwaControllerOptions* options; + const AeronControllerSnapshot* controller; + if (!input || slot < 0 || slot > 1) return NULL; + if (slot == 0) { + if (!g_controllerMapping.configured) return NULL; + options = &g_controllerMapping.options; + } else { + if (!g_controllerMapping.secondary_configured) return NULL; + options = &g_controllerMapping.secondary; + } + if (!options->enabled) return NULL; + controller = Aeron_SelectController(input, &options->device); + if (slot == 1 && controller && controller == XwaControllerMapping_ControllerForSlot(0)) + return NULL; /* Never apply the same device twice. */ + return controller; +} - if (!input || !g_controllerMapping.configured) { - return NULL; - } - return Aeron_SelectController(input, &g_controllerMapping.options.device); +const AeronControllerSnapshot* XwaControllerMapping_SelectedController(void) { + const AeronControllerSnapshot* first = XwaControllerMapping_ControllerForSlot(0); + return first ? first : XwaControllerMapping_ControllerForSlot(1); } static const XwaControllerProfile* ControllerMapping_Profile(const XwaControllerOptions* options, @@ -164,7 +188,8 @@ static int ControllerMapping_HasPov(const AeronControllerSnapshot* controller, return profile->pov_source >= 0 && profile->pov_source < controller->hat_count; } -static void ControllerMapping_LogUnavailableSources(const AeronControllerSnapshot* controller) { +static void ControllerMapping_LogUnavailableSources(const AeronControllerSnapshot* controller, + const XwaControllerOptions* options) { const XwaControllerProfile* profile; int unavailable_axes = 0; int unavailable_buttons = 0; @@ -174,7 +199,7 @@ static void ControllerMapping_LogUnavailableSources(const AeronControllerSnapsho if (!controller || controller->kind != AERON_CONTROLLER_KIND_JOYSTICK) { return; } - profile = &g_controllerMapping.options.joystick; + profile = &options->joystick; for (i = 0; i < XWA_CONTROLLER_LOGICAL_AXIS_COUNT; ++i) { unavailable_axes += profile->axes[i].source >= controller->axis_count; } @@ -199,7 +224,7 @@ static void ControllerMapping_LogUnavailableSources(const AeronControllerSnapsho static void ControllerMapping_MapSnapshot(const XwaControllerOptions* options, const AeronControllerSnapshot* controller, int has_focus, - uint32_t previous_axis_buttons, uint32_t* axis_buttons, + uint64_t previous_axis_buttons, uint64_t* axis_buttons, XwaControllerLogicalState* state) { const XwaControllerProfile* profile; int logical; @@ -222,6 +247,8 @@ static void ControllerMapping_MapSnapshot(const XwaControllerOptions* options, profile = ControllerMapping_Profile(options, controller); state->has_pov = ControllerMapping_HasPov(controller, profile); for (logical = 0; logical < XWA_CONTROLLER_LOGICAL_AXIS_COUNT; ++logical) { + /* Per-device Roll Enabled must not block another device's roll axis. */ + if (logical == XWA_CONTROLLER_AXIS_ROLL && !options->roll_enabled) continue; const XwaControllerAxisBinding* binding = &profile->axes[logical]; const int16_t value = ControllerMapping_AxisValue(controller, binding->source); state->source_axes[logical] = (int8_t)binding->source; @@ -239,9 +266,9 @@ static void ControllerMapping_MapSnapshot(const XwaControllerOptions* options, } for (logical = 0; logical < XWA_CONTROLLER_LOGICAL_BUTTON_COUNT; ++logical) { const AeronControllerDigitalSource* binding = &profile->buttons[logical]; - const uint32_t bit = 1u << logical; + const uint64_t bit = UINT64_C(1) << logical; if (Aeron_ControllerDigitalSourceDown(controller, binding, (previous_axis_buttons & bit) != 0)) { - state->buttons |= 1u << logical; + state->buttons |= UINT64_C(1) << logical; if (axis_buttons && binding->kind != AERON_CONTROLLER_DIGITAL_BUTTON) { *axis_buttons |= bit; } @@ -257,28 +284,37 @@ void XwaControllerMapping_MapSnapshot(const XwaControllerOptions* options, } void XwaControllerMapping_SetOptions(const XwaControllerOptions* options) { - const uint32_t old_instance_id = XwaControllerMapping_SelectedInstanceId(); - const uint32_t previous_instance_id = g_controllerMapping.previous_instance_id; - if (options && g_controllerMapping.configured && - ControllerMapping_OptionsEqual(options, &g_controllerMapping.options)) { + ControllerMapping_OptionsEqual(options, &g_controllerMapping.options)) return; - } - if (old_instance_id != 0) { - Aeron_RumbleController(old_instance_id, 0, 0, 0); - } - memset(&g_controllerMapping, 0, sizeof(g_controllerMapping)); - /* Option changes must not masquerade as physical device changes. */ - g_controllerMapping.previous_instance_id = previous_instance_id; - if (!options) { + if (g_controllerMapping.previous_instance_id) + Aeron_RumbleController(g_controllerMapping.previous_instance_id, 0, 0, 0); + if (options) g_controllerMapping.options = *options; + else memset(&g_controllerMapping.options, 0, sizeof g_controllerMapping.options); + g_controllerMapping.configured = options != NULL; + g_controllerMapping.digital_axis_buttons[0] = 0; + g_controllerMapping.pending_buttons[0] = 0; + g_controllerMapping.previous_pov[0] = -1; + g_controllerMapping.actions_instance_id[0] = 0; + g_controllerMapping.hyperdrive_instance_id[0] = 0; + g_controllerMapping.hyperdrive_armed[0] = 0; +} + +void XwaControllerMapping_SetSecondaryOptions(const XwaControllerOptions* options) { + if (options && g_controllerMapping.secondary_configured && + ControllerMapping_OptionsEqual(options, &g_controllerMapping.secondary)) return; - } - g_controllerMapping.options = *options; - g_controllerMapping.configured = 1; - if ((options->device.guid[0] || options->device.path[0]) && !XwaControllerMapping_SelectedController()) { - Aeron_LogWarn("xwa.input", "Configured controller is unavailable (GUID '%s', path '%s', ordinal %d)", - options->device.guid, options->device.path, options->device.ordinal); - } + if (g_controllerMapping.previous_secondary_id) + Aeron_RumbleController(g_controllerMapping.previous_secondary_id, 0, 0, 0); + if (options) g_controllerMapping.secondary = *options; + else memset(&g_controllerMapping.secondary, 0, sizeof g_controllerMapping.secondary); + g_controllerMapping.secondary_configured = options != NULL; + g_controllerMapping.digital_axis_buttons[1] = 0; + g_controllerMapping.pending_buttons[1] = 0; + g_controllerMapping.previous_pov[1] = -1; + g_controllerMapping.actions_instance_id[1] = 0; + g_controllerMapping.hyperdrive_instance_id[1] = 0; + g_controllerMapping.hyperdrive_armed[1] = 0; } uint32_t XwaControllerMapping_SelectedInstanceId(void) { @@ -291,61 +327,192 @@ int XwaControllerMapping_SelectedHasRumble(void) { return controller && controller->has_rumble; } -int XwaControllerMapping_Rumble(uint16_t low_frequency_rumble, uint16_t high_frequency_rumble, - uint32_t duration_ms) { - const AeronControllerSnapshot* controller = XwaControllerMapping_SelectedController(); - const int stopping = (low_frequency_rumble == 0 && high_frequency_rumble == 0) || duration_ms == 0; +int XwaControllerMapping_RumbleSlot(int slot, uint16_t low, uint16_t high, uint32_t duration_ms) { + const AeronControllerSnapshot* controller = XwaControllerMapping_ControllerForSlot(slot); + const XwaControllerOptions* opts = slot == 1 ? &g_controllerMapping.secondary : &g_controllerMapping.options; + if (!controller || !controller->has_rumble) return 0; + if (!opts->rumble_enabled && (low || high)) return 0; + return Aeron_RumbleController(controller->instance_id, low, high, duration_ms); +} - if (!controller || !controller->has_rumble) { - if (!stopping) { - Aeron_LogWarn("xwa.input", "Rumble requested while the selected controller is %s", - controller ? "unsupported" : "disconnected"); - } - return 0; - } - if (!stopping && !g_controllerMapping.options.rumble_enabled) { - return 0; - } - return Aeron_RumbleController(controller->instance_id, low_frequency_rumble, high_frequency_rumble, - duration_ms); +int XwaControllerMapping_Rumble(uint16_t low, uint16_t high, uint32_t duration_ms) { + return XwaControllerMapping_RumbleSlot(XwaControllerMapping_ControllerForSlot(0) ? 0 : 1, + low, high, duration_ms); } int XwaControllerMapping_ConsumeSelectionChange(void) { - const uint32_t instance_id = XwaControllerMapping_SelectedInstanceId(); - if (instance_id == g_controllerMapping.previous_instance_id) { - return 0; - } - if (g_controllerMapping.previous_instance_id != 0) { - Aeron_RumbleController(g_controllerMapping.previous_instance_id, 0, 0, 0); - } - Aeron_LogInfo("xwa.input", "Active controller changed from %u to %u", - g_controllerMapping.previous_instance_id, instance_id); - g_controllerMapping.previous_instance_id = instance_id; - g_controllerMapping.digital_axis_buttons = 0; - ControllerMapping_LogUnavailableSources(XwaControllerMapping_SelectedController()); - return 1; + int changed = 0; + for (int slot = 0; slot < 2; ++slot) { + const AeronControllerSnapshot* controller = XwaControllerMapping_ControllerForSlot(slot); + uint32_t id = controller ? controller->instance_id : 0; + uint32_t* previous = slot ? &g_controllerMapping.previous_secondary_id + : &g_controllerMapping.previous_instance_id; + if (id == *previous) continue; + if (*previous) Aeron_RumbleController(*previous, 0, 0, 0); + Aeron_LogInfo("xwa.input", "Controller %d changed from %u to %u", slot + 1, *previous, id); + *previous = id; + g_controllerMapping.digital_axis_buttons[slot] = 0; + g_controllerMapping.pending_buttons[slot] = 0; + g_controllerMapping.previous_pov[slot] = -1; + g_controllerMapping.hyperdrive_instance_id[slot] = 0; + g_controllerMapping.hyperdrive_armed[slot] = 0; + if (controller) ControllerMapping_LogUnavailableSources(controller, + slot ? &g_controllerMapping.secondary : &g_controllerMapping.options); + changed = 1; + } + return changed; } int XwaControllerMapping_GetState(XwaControllerLogicalState* state) { const AeronInputSnapshot* input = Aeron_InputSnapshot(); - const AeronControllerSnapshot* controller = XwaControllerMapping_SelectedController(); + int found = 0; + if (!state) return 0; + memset(state, 0, sizeof *state); + state->pov_direction = -1; + for (int i = 0; i < XWA_CONTROLLER_LOGICAL_AXIS_COUNT; ++i) { + state->axes[i] = CONTROLLER_AXIS_CENTER; + state->source_axes[i] = -1; + } + for (int slot = 0; slot < 2; ++slot) { + const AeronControllerSnapshot* controller = XwaControllerMapping_ControllerForSlot(slot); + const XwaControllerOptions* options = slot ? &g_controllerMapping.secondary : &g_controllerMapping.options; + XwaControllerLogicalState current; + if (!controller) continue; + found = 1; + ControllerMapping_MapSnapshot(options, controller, input && input->has_focus, + g_controllerMapping.digital_axis_buttons[slot], + &g_controllerMapping.digital_axis_buttons[slot], ¤t); + for (int axis = 0; axis < XWA_CONTROLLER_LOGICAL_AXIS_COUNT; ++axis) { + if (state->source_axes[axis] == -1 && current.source_axes[axis] >= 0) { + state->axes[axis] = current.axes[axis]; + state->source_axes[axis] = current.source_axes[axis]; + state->source_axis_values[axis] = current.source_axis_values[axis]; + } + } + state->buttons |= current.buttons; + if (!state->has_pov && current.has_pov) { + state->has_pov = 1; + state->pov_direction = current.pov_direction; + } + } + return found; +} - ControllerMapping_MapSnapshot(&g_controllerMapping.options, controller, input && input->has_focus, - g_controllerMapping.digital_axis_buttons, - &g_controllerMapping.digital_axis_buttons, state); - return controller != NULL; +int XwaControllerMapping_ConsumeHyperdriveEngage(void) { + const AeronInputSnapshot* input = Aeron_InputSnapshot(); + int engage = 0; + + /* Neither controller may retain an armed lever across focus loss. */ + if (!input || !input->has_focus) { + for (int slot = 0; slot < 2; ++slot) { + g_controllerMapping.hyperdrive_instance_id[slot] = 0; + g_controllerMapping.hyperdrive_armed[slot] = 0; + } + return 0; + } + for (int slot = 0; slot < 2; ++slot) { + const AeronControllerSnapshot* controller = XwaControllerMapping_ControllerForSlot(slot); + const XwaControllerOptions* options = slot ? &g_controllerMapping.secondary : &g_controllerMapping.options; + const XwaControllerProfile* profile; + const XwaControllerAxisBinding* binding; + uint32_t value; + int source; + + if (!controller || !controller->connected) { + g_controllerMapping.hyperdrive_instance_id[slot] = 0; + g_controllerMapping.hyperdrive_armed[slot] = 0; + continue; + } + if (controller->instance_id != g_controllerMapping.hyperdrive_instance_id[slot]) { + g_controllerMapping.hyperdrive_instance_id[slot] = controller->instance_id; + g_controllerMapping.hyperdrive_armed[slot] = 0; + } + profile = ControllerMapping_Profile(options, controller); + binding = &profile->axes[XWA_CONTROLLER_AXIS_HYPERDRIVE]; + source = binding->source; + if (source < 0 || source >= (controller->kind == AERON_CONTROLLER_KIND_GAMEPAD + ? AERON_GAMEPAD_AXIS_COUNT : controller->axis_count)) { + g_controllerMapping.hyperdrive_armed[slot] = 0; + continue; + } + value = ControllerMapping_IsTrigger(controller, source) + ? ControllerMapping_TriggerAxis(ControllerMapping_AxisValue(controller, source), + binding->invert, binding->deadzone) + : ControllerMapping_CenteredAxis(ControllerMapping_AxisValue(controller, source), + binding->invert, binding->deadzone); + engage |= XwaHyperdriveAxis_Update(value, &g_controllerMapping.hyperdrive_armed[slot]); + } + return engage; } -void XwaControllerMapping_CopySelectedActions(uint16_t actions[XWA_CONTROLLER_ACTION_COUNT]) { - const AeronControllerSnapshot* controller; - const XwaControllerProfile* profile; +void XwaControllerMapping_CopySelectedActions(uint16_t actions[20]) { + const AeronControllerSnapshot* controller = XwaControllerMapping_SelectedController(); + const XwaControllerOptions* opts = XwaControllerMapping_ControllerForSlot(0) + ? &g_controllerMapping.options : &g_controllerMapping.secondary; + const XwaControllerProfile* profile = + controller && controller->kind == AERON_CONTROLLER_KIND_JOYSTICK ? &opts->joystick : &opts->gamepad; + if (!actions) return; + for (int i = 0; i < 16; ++i) actions[i] = profile->actions[i]; + for (int i = 0; i < 4; ++i) actions[16+i] = profile->actions[XWA_CONTROLLER_LOGICAL_BUTTON_COUNT+i]; +} - if (!actions) { - return; +/* All modern controller actions (64 buttons plus POV on each physical device) + * are translated independently of WinMM's 16-button compatibility mask. + * A held switch contributes modifiers, but ordinary actions fire on a fresh + * press only; a keyboard key already in flight retains priority. */ +void XwaControllerMapping_ReadActions(uint16_t* key, int* key_mods) { + const AeronInputSnapshot* input = Aeron_InputSnapshot(); + if (!key || !key_mods || !input || !input->has_focus) return; + for (int slot = 0; slot < 2; ++slot) { + const AeronControllerSnapshot* controller = XwaControllerMapping_ControllerForSlot(slot); + const XwaControllerOptions* options = slot ? &g_controllerMapping.secondary : &g_controllerMapping.options; + const XwaControllerProfile* profile; + XwaControllerLogicalState state; + uint64_t previous, accepted = 0; + int direction; + if (!controller) { + g_controllerMapping.pending_buttons[slot] = 0; + g_controllerMapping.previous_pov[slot] = -1; + g_controllerMapping.actions_instance_id[slot] = 0; + continue; + } + profile = ControllerMapping_Profile(options, controller); + ControllerMapping_MapSnapshot(options, controller, 1, g_controllerMapping.digital_axis_buttons[slot], + NULL, &state); + /* On connection, do not synthesize presses from latching controls + * that were already on before the game/input device became active. */ + if (g_controllerMapping.actions_instance_id[slot] != controller->instance_id) { + g_controllerMapping.actions_instance_id[slot] = controller->instance_id; + g_controllerMapping.pending_buttons[slot] = state.buttons; + g_controllerMapping.previous_pov[slot] = state.pov_direction; + } + previous = g_controllerMapping.pending_buttons[slot]; + for (int i = 0; i < XWA_CONTROLLER_LOGICAL_BUTTON_COUNT; ++i) { + const uint64_t bit = UINT64_C(1) << i; + const uint16_t action = profile->actions[i]; + if (!(state.buttons & bit)) continue; + if (action == 156) *key_mods |= 1; + else if (action == 157) *key_mods |= 2; + else if (action && (action == 180 || action == 182 || action == 184 || action == 186 || + !(previous & bit))) { + if (!*key) { + *key = action; + accepted |= bit; + } + } + } + g_controllerMapping.pending_buttons[slot] = (state.buttons & previous) | accepted; + /* All released bits are cleared; modifiers are level-sensitive. */ + direction = state.has_pov ? state.pov_direction : -1; + if (direction >= 0) { + const uint16_t action = profile->actions[XWA_CONTROLLER_LOGICAL_BUTTON_COUNT + direction]; + if (action == 156) *key_mods |= 1; + else if (action == 157) *key_mods |= 2; + else if (action && (direction != g_controllerMapping.previous_pov[slot] || + action == 180 || action == 182 || action == 184 || action == 186)) { + if (!*key) *key = action; + } + } + g_controllerMapping.previous_pov[slot] = direction; } - controller = XwaControllerMapping_SelectedController(); - profile = controller && controller->kind == AERON_CONTROLLER_KIND_JOYSTICK - ? &g_controllerMapping.options.joystick - : &g_controllerMapping.options.gamepad; - memcpy(actions, profile->actions, sizeof(profile->actions)); } diff --git a/src/xwa_runtime/input/controller_mapping.h b/src/xwa_runtime/input/controller_mapping.h index 2f4185f0..a6eb5922 100644 --- a/src/xwa_runtime/input/controller_mapping.h +++ b/src/xwa_runtime/input/controller_mapping.h @@ -12,7 +12,7 @@ extern "C" { typedef struct XwaControllerLogicalState { uint32_t axes[XWA_CONTROLLER_LOGICAL_AXIS_COUNT]; - uint32_t buttons; + uint64_t buttons; int pov_direction; /* -1 centered, otherwise up/right/down/left = 0..3. */ int has_pov; int8_t source_axes[XWA_CONTROLLER_LOGICAL_AXIS_COUNT]; @@ -20,6 +20,10 @@ typedef struct XwaControllerLogicalState { } XwaControllerLogicalState; void XwaControllerMapping_SetOptions(const XwaControllerOptions* options); +void XwaControllerMapping_SetSecondaryOptions(const XwaControllerOptions* options); +const AeronControllerSnapshot* XwaControllerMapping_ControllerForSlot(int slot); +int XwaControllerMapping_RumbleSlot(int slot, uint16_t low, uint16_t high, uint32_t duration_ms); +void XwaControllerMapping_ReadActions(uint16_t* key, int* key_mods); const AeronControllerSnapshot* XwaControllerMapping_SelectedController(void); uint32_t XwaControllerMapping_SelectedInstanceId(void); int XwaControllerMapping_SelectedHasRumble(void); @@ -27,7 +31,9 @@ int XwaControllerMapping_Rumble(uint16_t low_frequency_rumble, uint16_t high_fre uint32_t duration_ms); int XwaControllerMapping_ConsumeSelectionChange(void); int XwaControllerMapping_GetState(XwaControllerLogicalState* state); -void XwaControllerMapping_CopySelectedActions(uint16_t actions[XWA_CONTROLLER_ACTION_COUNT]); +/* Rising edge of a lever on either configured physical controller. */ +int XwaControllerMapping_ConsumeHyperdriveEngage(void); +void XwaControllerMapping_CopySelectedActions(uint16_t actions[20]); /* Pure conversion entry point used by the runtime facade and unit tests. */ void XwaControllerMapping_MapSnapshot(const XwaControllerOptions* options, diff --git a/src/xwa_runtime/input/head_tracking.c b/src/xwa_runtime/input/head_tracking.c new file mode 100644 index 00000000..bde90291 --- /dev/null +++ b/src/xwa_runtime/input/head_tracking.c @@ -0,0 +1,76 @@ +/* Head tracking for the local player's camera. It keeps the pose of the + * current flight frame and switches between the two sources. */ +#include "xwa_runtime/input/head_tracking.h" +#include "xwa_runtime/config/modern_input_options.h" +#include "xwa_runtime/input/opentrack_udp.h" +#include "xwa_runtime/input/trackir.h" + +#include + +/* Stored separately from recovered simulation/player state. */ +static XwaHeadPose s_pose; +static int s_pose_valid; +static float s_camera_offset[3]; + +void XwaHeadTracking_ClearPose(void) { + memset(&s_pose, 0, sizeof s_pose); + memset(s_camera_offset, 0, sizeof s_camera_offset); + s_pose_valid = 0; +} + +void XwaHeadTracking_SetCameraOffset(const float world_offset[3]) { + if (world_offset && s_pose_valid) { + memcpy(s_camera_offset, world_offset, sizeof s_camera_offset); + } +} + +void XwaHeadTracking_GetCameraOffset(float world_offset[3]) { + if (world_offset) { + memcpy(world_offset, s_camera_offset, sizeof s_camera_offset); + } +} + +int XwaHeadTracking_CurrentPose(XwaHeadPose* out) { + if (out) { + *out = s_pose; + } + return s_pose_valid; +} + +void XwaHeadTracking_Shutdown(void) { + XwaOpenTrackUdp_Shutdown(); + XwaTrackIR_Shutdown(); + XwaHeadTracking_ClearPose(); +} + +int XwaHeadTracking_Poll(XwaHeadPose* out) { + XwaModernInputOptions options; + int polled; + + if (!out) { + return 0; + } + memset(out, 0, sizeof *out); + XwaHeadTracking_ClearPose(); + XwaModernInputOptions_Get(&options); + if (!options.head_tracking.enabled) { + /* Disabled means no connection, polling, or leftover pose. */ + XwaOpenTrackUdp_Shutdown(); + XwaTrackIR_Shutdown(); + return 0; + } + /* Only one source stays connected; the other is released on a switch. */ + if (options.head_tracking.source == XWA_HEAD_TRACK_SOURCE_OPENTRACK_UDP) { + XwaTrackIR_Shutdown(); + polled = XwaOpenTrackUdp_Poll(out, &options.head_tracking); + } else { + XwaOpenTrackUdp_Shutdown(); + polled = XwaTrackIR_Poll(out, &options.head_tracking); + } + if (!polled) { + return 0; + } + s_pose = *out; + s_pose_valid = 1; + return 1; +} diff --git a/src/xwa_runtime/input/head_tracking.h b/src/xwa_runtime/input/head_tracking.h new file mode 100644 index 00000000..e7df0c6f --- /dev/null +++ b/src/xwa_runtime/input/head_tracking.h @@ -0,0 +1,29 @@ +#ifndef XWA_RUNTIME_HEAD_TRACKING_H +#define XWA_RUNTIME_HEAD_TRACKING_H + +#include + +/* The local player's head pose, from whichever source the options select: + * native TrackIR (Windows) or OpenTrack UDP. */ +typedef struct XwaHeadPose { + int16_t yaw_q16; /* negative = look left */ + int16_t pitch_q16; /* positive = look up */ + int16_t roll_q16; + float left_cm; + float up_cm; + float back_cm; +} XwaHeadPose; + +/* Returns zero if disabled, unavailable, unfocused, or the data is stale. */ +int XwaHeadTracking_Poll(XwaHeadPose* out); +/* Last pose sampled for the current flight frame; zero when tracking is unavailable. */ +int XwaHeadTracking_CurrentPose(XwaHeadPose* out); +void XwaHeadTracking_ClearPose(void); +/* Transient camera displacement in world/OPT units, not saved flight state. + * The cockpit renderer needs this to keep the mesh anchored to the craft. */ +void XwaHeadTracking_SetCameraOffset(const float world_offset[3]); +void XwaHeadTracking_GetCameraOffset(float world_offset[3]); +/* Closes whichever source is connected and clears the pose. */ +void XwaHeadTracking_Shutdown(void); + +#endif diff --git a/src/xwa_runtime/input/hyperdrive_axis.h b/src/xwa_runtime/input/hyperdrive_axis.h new file mode 100644 index 00000000..1f6f76c5 --- /dev/null +++ b/src/xwa_runtime/input/hyperdrive_axis.h @@ -0,0 +1,22 @@ +#ifndef XWA_RUNTIME_INPUT_HYPERDRIVE_AXIS_H +#define XWA_RUNTIME_INPUT_HYPERDRIVE_AXIS_H + +#include + +/* A lever parked near -1.0 arms; moving it past -0.8 fires exactly once. + * The 0..65535 mapped axis has no dependency on frame rate or key-repeat. + * A new engagement requires returning to the lower five percent. */ +static inline int XwaHyperdriveAxis_Update(uint32_t value, int* armed) { + if (!armed) return 0; + if (value <= 1638u) { + *armed = 1; + return 0; + } + if (value >= 6554u && *armed) { + *armed = 0; + return 1; + } + return 0; +} + +#endif diff --git a/src/xwa_runtime/input/opentrack_udp.c b/src/xwa_runtime/input/opentrack_udp.c new file mode 100644 index 00000000..d8ddb611 --- /dev/null +++ b/src/xwa_runtime/input/opentrack_udp.c @@ -0,0 +1,190 @@ +/* OpenTrack "UDP over network" output: + * 48 bytes, six little-endian IEEE754 doubles: X,Y,Z (cm), yaw,pitch,roll + * (degrees). Bind loopback only; neither OpenTrack nor its libraries are linked. */ +#include "xwa_runtime/input/opentrack_udp.h" +#include "xwa_runtime/timing/host_clock.h" + +#include +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#include +typedef SOCKET OpenTrackSocket; +#define OT_INVALID_SOCKET INVALID_SOCKET +#else +#include +#include +#include +#include +#include +typedef int OpenTrackSocket; +#define OT_INVALID_SOCKET (-1) +#endif + +enum { + OPENTRACK_UDP_PORT = 4242, + OPENTRACK_STALE_MS = 500, + OPENTRACK_RETRY_MS = 3000, + OPENTRACK_MAX_DRAIN = 64 +}; + +static OpenTrackSocket s_socket = OT_INVALID_SOCKET; +static uint32_t s_next_attempt_ms; +static uint32_t s_last_received_ms; +static int s_last_pose_valid; +static XwaHeadPose s_last_pose; +#ifdef _WIN32 +static int s_winsock_started; +#endif + +void XwaOpenTrackUdp_Shutdown(void) { + if (s_socket != OT_INVALID_SOCKET) { +#ifdef _WIN32 + closesocket(s_socket); +#else + close(s_socket); +#endif + s_socket = OT_INVALID_SOCKET; + } +#ifdef _WIN32 + if (s_winsock_started) { + WSACleanup(); + s_winsock_started = 0; + } +#endif + s_next_attempt_ms = 0; + s_last_received_ms = 0; + s_last_pose_valid = 0; + memset(&s_last_pose, 0, sizeof s_last_pose); +} + +static int open_loopback_listener(void) { + struct sockaddr_in bind_address; +#ifdef _WIN32 + u_long nonblocking = 1; + WSADATA wsa_data; + if (WSAStartup(MAKEWORD(2, 2), &wsa_data) != 0) + return 0; + s_winsock_started = 1; +#else + int flags; +#endif + + s_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (s_socket == OT_INVALID_SOCKET) + goto failed; +#ifdef _WIN32 + if (ioctlsocket(s_socket, FIONBIO, &nonblocking) != 0) + goto failed; +#else + flags = fcntl(s_socket, F_GETFL, 0); + if (flags < 0 || fcntl(s_socket, F_SETFL, flags | O_NONBLOCK) < 0) + goto failed; +#endif + + memset(&bind_address, 0, sizeof bind_address); + bind_address.sin_family = AF_INET; + bind_address.sin_port = htons(OPENTRACK_UDP_PORT); + bind_address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (bind(s_socket, (const struct sockaddr*)&bind_address, sizeof bind_address) != 0) + goto failed; + return 1; + +failed: + XwaOpenTrackUdp_Shutdown(); + return 0; +} + +/* Bytewise decode avoids aliasing, alignment, and host-endian assumptions. */ +static double read_le_double(const unsigned char* bytes) { + uint64_t bits = 0; + double value; + for (int i = 0; i < 8; ++i) { + bits |= (uint64_t)bytes[i] << (i * 8); + } + memcpy(&value, &bits, sizeof value); + return value; +} + +static int16_t degrees_to_q16(double angle, int sign) { + double scaled = angle * (32768.0 / 180.0) * (double)sign; + if (scaled > 32767.0) + scaled = 32767.0; + if (scaled < -32767.0) + scaled = -32767.0; + return (int16_t)lrint(scaled); +} + +static int decode_packet(const unsigned char* bytes, int size, const XwaHeadTrackingOptions* options, + XwaHeadPose* pose) { + double values[6]; + if (!bytes || size != 48 || !options || !pose || sizeof(double) != 8) + return 0; + for (int i = 0; i < 6; ++i) { + values[i] = read_le_double(&bytes[8 * i]); + if (!isfinite(values[i])) + return 0; + } + /* Reject wildly out-of-range input rather than moving the camera + * through geometry. OpenTrack normally sends far smaller values. */ + for (int i = 0; i < 3; ++i) { + if (fabs(values[i]) > 200.0) + return 0; + } + for (int i = 3; i < 6; ++i) { + if (fabs(values[i]) > 360.0) + return 0; + } + + /* OpenTrack convention: +X right, +Y up. The common pose uses +left, + * +up and +back; inversion switches remain user-configurable. */ + pose->left_cm = (float)(-values[0]) * (options->invert[XWA_HEAD_TRACK_AXIS_X] ? -1.0f : 1.0f); + pose->up_cm = (float)values[1] * (options->invert[XWA_HEAD_TRACK_AXIS_Y] ? -1.0f : 1.0f); + pose->back_cm = (float)values[2] * (options->invert[XWA_HEAD_TRACK_AXIS_Z] ? -1.0f : 1.0f); + pose->yaw_q16 = degrees_to_q16(values[3], options->invert[XWA_HEAD_TRACK_AXIS_YAW] ? -1 : 1); + pose->pitch_q16 = degrees_to_q16(values[4], options->invert[XWA_HEAD_TRACK_AXIS_PITCH] ? -1 : 1); + pose->roll_q16 = degrees_to_q16(values[5], options->invert[XWA_HEAD_TRACK_AXIS_ROLL] ? -1 : 1); + return 1; +} + +int XwaOpenTrackUdp_Poll(XwaHeadPose* pose, const XwaHeadTrackingOptions* options) { + uint32_t now = XwaTime_GetElapsedTicks(); + unsigned char packet[49]; /* One extra byte detects oversized datagrams. */ + XwaHeadPose latest; + + if (!pose || !options) + return 0; + memset(pose, 0, sizeof *pose); + + if (s_socket == OT_INVALID_SOCKET) { + if (s_next_attempt_ms && (int32_t)(now - s_next_attempt_ms) < 0) + return 0; + if (!open_loopback_listener()) { + s_next_attempt_ms = now + OPENTRACK_RETRY_MS; + return 0; + } + } + + /* Drain queued packets without blocking the simulation, preferring the + * newest pose. Bound the work so a flooded socket cannot stall a frame. */ + for (int i = 0; i < OPENTRACK_MAX_DRAIN; ++i) { + int received = (int)recv(s_socket, (char*)packet, (int)sizeof packet, 0); + if (received < 0) + break; /* No pending data on a nonblocking socket. */ + if (decode_packet(packet, received, options, &latest)) { + s_last_pose = latest; + s_last_received_ms = now; + s_last_pose_valid = 1; + } + } + + if (!s_last_pose_valid || (uint32_t)(now - s_last_received_ms) > OPENTRACK_STALE_MS) { + s_last_pose_valid = 0; + return 0; + } + *pose = s_last_pose; + return 1; +} diff --git a/src/xwa_runtime/input/opentrack_udp.h b/src/xwa_runtime/input/opentrack_udp.h new file mode 100644 index 00000000..a9b278db --- /dev/null +++ b/src/xwa_runtime/input/opentrack_udp.h @@ -0,0 +1,13 @@ +#ifndef XWA_RUNTIME_OPENTRACK_UDP_H +#define XWA_RUNTIME_OPENTRACK_UDP_H + +#include "xwa_runtime/config/modern_input_options.h" +#include "xwa_runtime/input/head_tracking.h" + +/* Loopback-only OpenTrack "UDP over network" provider, port 4242. + * Returns the latest valid pose, or zero when unavailable/stale. + * Does not depend on the OpenTrack application or its libraries. */ +int XwaOpenTrackUdp_Poll(XwaHeadPose* pose, const XwaHeadTrackingOptions* options); +void XwaOpenTrackUdp_Shutdown(void); + +#endif diff --git a/src/xwa_runtime/input/trackir.c b/src/xwa_runtime/input/trackir.c new file mode 100644 index 00000000..0f1cd2c3 --- /dev/null +++ b/src/xwa_runtime/input/trackir.c @@ -0,0 +1,186 @@ +/* Native TrackIR source. It loads the NPClient64.dll that TrackIR installs; + * no NaturalPoint SDK headers, code, or DLLs are distributed with OpenXWA. */ +#include "xwa_runtime/input/trackir.h" + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include + +/* Minimal ABI for the TrackIR data endpoint (not the SDK implementation). */ +#pragma pack(push, 1) +typedef struct TrackIRRawFrame { + uint16_t status; + uint16_t signature; + uint32_t reserved; + float roll, pitch, yaw; + float x, y, z; + float remaining[9]; +} TrackIRRawFrame; +#pragma pack(pop) + +typedef int(__stdcall* TrackIRRegisterWindow)(HWND); +typedef int(__stdcall* TrackIRUnregisterWindow)(void); +typedef int(__stdcall* TrackIRRegisterProfile)(uint16_t); +typedef int(__stdcall* TrackIRRequestData)(uint16_t); +typedef int(__stdcall* TrackIRTransmission)(void); +typedef int(__stdcall* TrackIRGetData)(TrackIRRawFrame*); + +static HMODULE s_module; +static HWND s_window; +static TrackIRUnregisterWindow s_unregister; +static TrackIRTransmission s_stop; +static TrackIRGetData s_get; +static ULONGLONG s_next_attempt; +static ULONGLONG s_last_data_time; +static uint16_t s_signature; +static int s_has_signature; + +static int16_t angle_q16(float raw, int invert) { + float value = raw * (32768.0f / 16383.0f) * (invert ? -1.0f : 1.0f); + if (value > 32767.0f) { + value = 32767.0f; + } + if (value < -32767.0f) { + value = -32767.0f; + } + return (int16_t)lrintf(value); +} + +void XwaTrackIR_Shutdown(void) { + if (s_module) { + if (s_stop) { + s_stop(); + } + if (s_unregister) { + s_unregister(); + } + FreeLibrary(s_module); + } + s_module = NULL; + s_window = NULL; + s_unregister = NULL; + s_stop = NULL; + s_get = NULL; + s_has_signature = 0; + s_last_data_time = 0; + s_next_attempt = 0; +} + +static int trackir_connect(void) { + wchar_t dll_path[MAX_PATH]; + DWORD bytes = sizeof(dll_path); + DWORD pid = 0; + HWND hwnd = GetActiveWindow(); + TrackIRRegisterWindow reg_window; + TrackIRRegisterProfile reg_profile; + TrackIRRequestData request; + TrackIRTransmission start; + size_t len; + + if (!hwnd) { + hwnd = GetForegroundWindow(); + } + if (!hwnd) { + return 0; + } + GetWindowThreadProcessId(hwnd, &pid); + if (pid != GetCurrentProcessId()) { + return 0; + } + if (RegGetValueW(HKEY_CURRENT_USER, L"Software\\NaturalPoint\\NATURALPOINT\\NPClient Location", L"Path", + RRF_RT_REG_SZ, NULL, dll_path, &bytes) != ERROR_SUCCESS) { + return 0; + } + len = wcslen(dll_path); + if (len > 0 && dll_path[len - 1] != L'\\' && dll_path[len - 1] != L'/') { + if (len + 1 >= MAX_PATH) { + return 0; + } + dll_path[len++] = L'\\'; + } + if (len + (sizeof(L"NPClient64.dll") / sizeof(wchar_t)) > MAX_PATH) { + return 0; + } + wcscpy(dll_path + len, L"NPClient64.dll"); + s_module = LoadLibraryW(dll_path); + if (!s_module) { + return 0; + } + + reg_window = (TrackIRRegisterWindow)GetProcAddress(s_module, "NP_RegisterWindowHandle"); + s_unregister = (TrackIRUnregisterWindow)GetProcAddress(s_module, "NP_UnregisterWindowHandle"); + reg_profile = (TrackIRRegisterProfile)GetProcAddress(s_module, "NP_RegisterProgramProfileID"); + request = (TrackIRRequestData)GetProcAddress(s_module, "NP_RequestData"); + start = (TrackIRTransmission)GetProcAddress(s_module, "NP_StartDataTransmission"); + s_stop = (TrackIRTransmission)GetProcAddress(s_module, "NP_StopDataTransmission"); + s_get = (TrackIRGetData)GetProcAddress(s_module, "NP_GetData"); + if (!reg_window || !s_unregister || !reg_profile || !request || !start || !s_stop || !s_get || + reg_window(hwnd) != 0) { + XwaTrackIR_Shutdown(); + return 0; + } + s_window = hwnd; + /* 1000 is NaturalPoint's public SDK/developer test profile. */ + if (reg_profile(1000) != 0 || request(1 | 2 | 4 | 16 | 32 | 64) != 0 || start() != 0) { + XwaTrackIR_Shutdown(); + return 0; + } + return 1; +} + +int XwaTrackIR_Poll(XwaHeadPose* out, const XwaHeadTrackingOptions* options) { + const ULONGLONG now = GetTickCount64(); + const float cm_scale = 50.0f / 16383.0f; + TrackIRRawFrame frame; + + if (!s_module) { + if (now < s_next_attempt) { + return 0; + } + if (!trackir_connect()) { + /* A failed connect clears the deadline; never try loading the DLL + every render frame. */ + s_next_attempt = now + 3000; + return 0; + } + } + if (s_window != GetForegroundWindow()) { + return 0; + } + memset(&frame, 0, sizeof frame); + if (s_get(&frame) != 0 || frame.status != 0) { + return 0; + } + if (!isfinite(frame.yaw) || !isfinite(frame.pitch) || !isfinite(frame.roll) || !isfinite(frame.x) || + !isfinite(frame.y) || !isfinite(frame.z)) { + return 0; + } + if (!s_has_signature || frame.signature != s_signature) { + s_signature = frame.signature; + s_has_signature = 1; + s_last_data_time = now; + } else if (now - s_last_data_time > 500) { + return 0; + } + + out->yaw_q16 = angle_q16(frame.yaw, !options->invert[XWA_HEAD_TRACK_AXIS_YAW]); + out->pitch_q16 = angle_q16(frame.pitch, !options->invert[XWA_HEAD_TRACK_AXIS_PITCH]); + out->roll_q16 = angle_q16(frame.roll, !options->invert[XWA_HEAD_TRACK_AXIS_ROLL]); + out->left_cm = frame.x * cm_scale * (options->invert[XWA_HEAD_TRACK_AXIS_X] ? -1.0f : 1.0f); + /* NaturalPoint's vertical value is reversed from the seat-up direction. */ + out->up_cm = -frame.y * cm_scale * (options->invert[XWA_HEAD_TRACK_AXIS_Y] ? -1.0f : 1.0f); + out->back_cm = frame.z * cm_scale * (options->invert[XWA_HEAD_TRACK_AXIS_Z] ? -1.0f : 1.0f); + return 1; +} +#else +void XwaTrackIR_Shutdown(void) {} + +int XwaTrackIR_Poll(XwaHeadPose* out, const XwaHeadTrackingOptions* options) { + (void)out; + (void)options; + return 0; +} +#endif diff --git a/src/xwa_runtime/input/trackir.h b/src/xwa_runtime/input/trackir.h new file mode 100644 index 00000000..540d46a1 --- /dev/null +++ b/src/xwa_runtime/input/trackir.h @@ -0,0 +1,13 @@ +#ifndef XWA_RUNTIME_TRACKIR_H +#define XWA_RUNTIME_TRACKIR_H + +#include "xwa_runtime/config/modern_input_options.h" +#include "xwa_runtime/input/head_tracking.h" + +/* Native TrackIR source, through the NPClient64.dll that TrackIR installs. + * Windows only; elsewhere the poll always returns zero. No NaturalPoint SDK + * headers, code, or DLLs are distributed. */ +int XwaTrackIR_Poll(XwaHeadPose* pose, const XwaHeadTrackingOptions* options); +void XwaTrackIR_Shutdown(void); + +#endif diff --git a/src/xwa_runtime/input/winmm_joystick_provider.c b/src/xwa_runtime/input/winmm_joystick_provider.c index e58b936a..f9db3edc 100644 --- a/src/xwa_runtime/input/winmm_joystick_provider.c +++ b/src/xwa_runtime/input/winmm_joystick_provider.c @@ -24,10 +24,12 @@ static int XwaWinmmJoystick_Source(AeronWinmmJoystickState* out, void* user) { out->axes[1] = state.axes[XWA_CONTROLLER_AXIS_PITCH]; out->axes[2] = state.axes[XWA_CONTROLLER_AXIS_THROTTLE]; out->axes[3] = state.axes[XWA_CONTROLLER_AXIS_ROLL]; - out->buttons = state.buttons; + /* Modern button actions are dispatched directly for all 64 slots per + * controller; exposing them again through WinMM would double-fire. */ + out->buttons = 0; out->button_count = XWA_CONTROLLER_LOGICAL_BUTTON_COUNT; - out->pov_direction = state.pov_direction; - out->has_pov = state.has_pov; + out->pov_direction = -1; + out->has_pov = 0; out->name = controller->name; memset(&trace, 0, sizeof(trace)); diff --git a/src/xwa_runtime/runtime/flight_task.c b/src/xwa_runtime/runtime/flight_task.c index 948c502f..99ccc78c 100644 --- a/src/xwa_runtime/runtime/flight_task.c +++ b/src/xwa_runtime/runtime/flight_task.c @@ -1,5 +1,6 @@ #include "xwa_runtime/runtime/flight_task.h" #include "xwa/flight/hangar.h" +#include "xwa_runtime/input/head_tracking.h" #include "xwa_runtime/runtime/film_benchmark.h" #include "xwa_runtime/runtime/flight_pause_task.h" #include "xwa_runtime/runtime/port.h" @@ -1977,6 +1978,7 @@ static void XwaFlightTask_TickPhases(int now) { } int XwaFlightTask_Shutdown(void) { + XwaHeadTracking_Shutdown(); XwaFlightPauseTask_Shutdown(); XwaModernFlightTiming_EndSession(); if (!g_xwaFlightTaskActive && !g_xwaFlightTaskComplete) { diff --git a/src/xwa_runtime/snapshot/snapshot.c b/src/xwa_runtime/snapshot/snapshot.c index c1e36e06..65edb245 100644 --- a/src/xwa_runtime/snapshot/snapshot.c +++ b/src/xwa_runtime/snapshot/snapshot.c @@ -15,6 +15,7 @@ #include "xwa_runtime/frontend/frontend_geometry.h" #include "xwa_runtime/frontend/frontend_screens.h" #include "xwa_runtime/frontend/frontend_video.h" +#include "xwa_runtime/input/head_tracking.h" #include "xwa_runtime/models/articulation.h" #include "xwa_runtime/models/cockpit_lights.h" #include "xwa_runtime/models/cockpit_view.h" @@ -1925,7 +1926,17 @@ void XwaSnapshot_CaptureFlight(void) { c->world_pos[0] = vs->savedTargetX; c->world_pos[1] = vs->savedTargetY; c->world_pos[2] = vs->savedTargetZ; - XwaSnapshotCamera_PositionFraction(c->world_pos, c->world_pos_fraction); + float head_world_offset[3] = { 0 }; + int32_t untracked_pos[3]; + XwaHeadTracking_GetCameraOffset(head_world_offset); + for (int axis = 0; axis < 3; ++axis) { + /* Lookup the existing camera fraction using its position before head tracking. */ + untracked_pos[axis] = c->world_pos[axis] - (int32_t)lroundf(head_world_offset[axis]); + } + XwaSnapshotCamera_PositionFraction(untracked_pos, c->world_pos_fraction); + for (int axis = 0; axis < 3; ++axis) { + c->world_pos_fraction[axis] += head_world_offset[axis] - roundf(head_world_offset[axis]); + } FVIEW_CopyRenderCameraRows(c->rows); c->view_pitch = vs->viewPitch; c->view_yaw = vs->viewYaw; @@ -1944,7 +1955,14 @@ void XwaSnapshot_CaptureFlight(void) { const int hyperspace_camera = c->hyperspace_phase && XwaSnapshotCamera_OrdinaryCamera(c->rows, c->world_pos, hyperspace_eye_offset); if (hyperspace_camera) { - XwaSnapshotCamera_PositionFraction(c->world_pos, c->world_pos_fraction); + /* The kept camera was placed with the head lean, as above. */ + for (int axis = 0; axis < 3; ++axis) { + untracked_pos[axis] = c->world_pos[axis] - (int32_t)lroundf(head_world_offset[axis]); + } + XwaSnapshotCamera_PositionFraction(untracked_pos, c->world_pos_fraction); + for (int axis = 0; axis < 3; ++axis) { + c->world_pos_fraction[axis] += head_world_offset[axis] - roundf(head_world_offset[axis]); + } } c->region = g_players[g_localPlayer].regionIndex; c->mission_region = @@ -2072,6 +2090,7 @@ void XwaSnapshot_CaptureFlight(void) { k->stick_axis[0] = g_replayInputs[g_localPlayer].axisX; k->stick_axis[1] = g_replayInputs[g_localPlayer].axisY; k->stick_axis[2] = g_replayInputs[g_localPlayer].axisR; + XwaHeadTracking_GetCameraOffset(k->head_offset); const char* name = XwaSnapshotExport_OptHandleName(handle); if (name != NULL) { snprintf(k->model_name, sizeof k->model_name, "%s", name); diff --git a/src/xwa_runtime/snapshot/snapshot.h b/src/xwa_runtime/snapshot/snapshot.h index 5644428c..c1652316 100644 --- a/src/xwa_runtime/snapshot/snapshot.h +++ b/src/xwa_runtime/snapshot/snapshot.h @@ -698,6 +698,7 @@ typedef struct XwaCockpit { * world axes (player.seatEyeWorldOffset*). */ float seat_eye_world_offset[3]; float camera_pan[3]; /* viewState.cameraPanDelta* (classic scales by 1/16) */ + float head_offset[3]; /* transient observer translation, OPT units */ /* The same eye point in the CRAFT's model space (player.seatEyeLocal*, * FlightView_ComputeSeatEyeOffset): the cockpit eye, or the turret * seat's position. The cockpit lights are here. */ @@ -1089,6 +1090,10 @@ typedef struct XwaHudReticle { int16_t look_yaw, look_pitch; uint8_t seat; uint8_t turret_auto_fire; + /* Transient direction of the turret boresight, before head tracking rotates + * the observer. Used only to draw the reticle in a turret seat. */ + uint8_t turret_aim_valid; + float turret_aim_world[3]; /* Mouse flight virtual-stick marker (position mode): held deflection in * [-127, 127], drawn by the HD HUD relative to the reticle center. */ uint8_t stick_marker; diff --git a/src/xwa_runtime/snapshot/snapshot_hud.c b/src/xwa_runtime/snapshot/snapshot_hud.c index 42a477d4..a7027e51 100644 --- a/src/xwa_runtime/snapshot/snapshot_hud.c +++ b/src/xwa_runtime/snapshot/snapshot_hud.c @@ -11,6 +11,7 @@ #include "xwa/flight/player/player.h" #include "xwa/render/renderer.h" #include "xwa_runtime/camera/exterior_hud.h" +#include "xwa_runtime/input/head_tracking.h" #include "xwa_runtime/input/mouse_flight.h" #include "xwa_runtime/models/craft_hud.h" #include "xwa_runtime/models/craft_weapons.h" @@ -504,7 +505,19 @@ static void hud_capture_direct_state(XwaHudState* out) { out->reticle.missile_lock_state = player->missileLockState; out->reticle.look_yaw = player->lookYawOffset; out->reticle.look_pitch = player->lookPitchOffset; + XwaHeadPose head; + const int headLook = XwaHeadTracking_CurrentPose(&head) && (head.yaw_q16 || head.pitch_q16); + if (headLook) { + out->reticle.look_yaw = (int16_t)(out->reticle.look_yaw + head.yaw_q16); + out->reticle.look_pitch = (int16_t)(out->reticle.look_pitch + head.pitch_q16); + } out->reticle.seat = (uint8_t)player->currentSeatIdx; + out->reticle.turret_aim_valid = (uint8_t)(headLook && player->currentSeatIdx > 0); + if (out->reticle.turret_aim_valid) { + for (int axis = 0; axis < 3; ++axis) { + out->reticle.turret_aim_world[axis] = (float)player->turretCamMat[axis] * (1.0f / 32768.0f); + } + } out->reticle.stick_marker = 0; if (!g_padlockMouseLookEnabled) { int stick_marker_x; diff --git a/tests/hyperdrive_axis_regression.c b/tests/hyperdrive_axis_regression.c new file mode 100644 index 00000000..a668833c --- /dev/null +++ b/tests/hyperdrive_axis_regression.c @@ -0,0 +1,27 @@ +#include "xwa_runtime/input/hyperdrive_axis.h" + +#include +#include + +int main(void) { + int armed = 0; + assert(!XwaHyperdriveAxis_Update(65535, &armed)); /* already forward at startup */ + assert(!XwaHyperdriveAxis_Update(1638, &armed)); /* near lower stop arms */ + assert(armed); + assert(!XwaHyperdriveAxis_Update(5000, &armed)); /* movement below threshold */ + assert(armed); + assert(!XwaHyperdriveAxis_Update(6553, &armed)); /* still below -0.8 */ + assert(XwaHyperdriveAxis_Update(6554, &armed)); /* crosses -0.8 */ + assert(!armed); + assert(!XwaHyperdriveAxis_Update(65535, &armed)); /* no repeated activation */ + assert(!XwaHyperdriveAxis_Update(2000, &armed)); /* not returned far enough */ + assert(!XwaHyperdriveAxis_Update(1638, &armed)); + assert(XwaHyperdriveAxis_Update(6554, &armed)); /* rearmed */ + assert(!XwaHyperdriveAxis_Update(6554, &armed)); + armed = 0; /* simulated disconnect or focus loss */ + assert(!XwaHyperdriveAxis_Update(65535, &armed)); + assert(!XwaHyperdriveAxis_Update(0, &armed)); + assert(XwaHyperdriveAxis_Update(6554, &armed)); + assert(!XwaHyperdriveAxis_Update(6554, NULL)); + return 0; +}