From f1164f369d59560aee099b23e8d7c36a46fe7320 Mon Sep 17 00:00:00 2001 From: Rasi Talon Date: Tue, 3 Nov 2026 00:14:52 +0200 Subject: [PATCH 01/10] feat(frontend): restore optional smooth menu fonts --- src/xwa_app/host_config.c | 18 +++++++++-- src/xwa_remaster/assets.c | 32 ++++++++++++++++--- src/xwa_remaster/assets.h | 6 ++-- src/xwa_remaster/xwa_remaster.c | 12 +++++-- src/xwa_runtime/config/modern_video_options.c | 6 ++-- src/xwa_runtime/config/modern_video_options.h | 2 ++ .../config/modern_video_options_screen.c | 7 +++- 7 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/xwa_app/host_config.c b/src/xwa_app/host_config.c index 2b51145c..01bd29b6 100644 --- a/src/xwa_app/host_config.c +++ b/src/xwa_app/host_config.c @@ -673,6 +673,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; } @@ -1292,7 +1304,8 @@ static int host_config_set_video_options(AeronConfigFile* document, const XwaMod options->motion_blur_amount > 1.0f || options->sdr_gamma < XWA_MODERN_SDR_GAMMA_2_2 || options->sdr_gamma > XWA_MODERN_SDR_GAMMA_SRGB || options->paper_white < XWA_MODERN_PAPER_WHITE_AUTO || - options->paper_white > XWA_MODERN_PAPER_WHITE_400) { + options->paper_white > XWA_MODERN_PAPER_WHITE_400 || + (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], @@ -1312,7 +1325,8 @@ static int host_config_set_video_options(AeronConfigFile* document, const XwaMod AeronConfigFile_SetString(document, "video.sdr_content_gamma", sdr_gamma_names[options->sdr_gamma], error) && AeronConfigFile_SetString(document, "video.paper_white_nits", - paper_white_names[options->paper_white], error); + paper_white_names[options->paper_white], 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, diff --git a/src/xwa_remaster/assets.c b/src/xwa_remaster/assets.c index 6b0ba22e..ed06e47b 100644 --- a/src/xwa_remaster/assets.c +++ b/src/xwa_remaster/assets.c @@ -42,7 +42,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 @@ -139,6 +140,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; @@ -165,13 +167,14 @@ struct XwaRemasterAssets { uint32_t frame_set_updates; /* counts XwaRemasterAssets_UpdateFrameSets calls */ }; -XwaRemasterAssets* XwaRemasterAssets_Create(const char* root, int prefer_original_2d) { +XwaRemasterAssets* XwaRemasterAssets_Create(const char* root, int prefer_original_2d, int smooth_menu_fonts) { XwaRemasterAssets* a = (XwaRemasterAssets*)calloc(1, sizeof *a); if (!a) { return NULL; } snprintf(a->root, sizeof a->root, "%s", root ? root : ""); a->prefer_original_2d = prefer_original_2d != 0; + a->smooth_menu_fonts = smooth_menu_fonts != 0; a->mission_index = -1; /* The frame sets use their own random numbers, so they don't change the game's rand() sequence. */ @@ -207,6 +210,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); @@ -1492,6 +1496,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, @@ -1510,6 +1519,13 @@ static AssetLoadStatus assets_load_original_frontend_font(XwaRemasterAssets* a, Xwa2dFontAtlas_Free(&font); return ASSET_LOAD_FAILED; } + /* Preload both variants so the menu can switch immediately. A failure of + * the optional 1x atlas must not hide the original 4x font. */ + if (!assets_init_original_font(cmd, &font, 1, 0, "XWA smooth frontend font", &slot->smooth_atlas)) { + AeronFontAtlas_Release(&slot->smooth_atlas); + memset(&slot->smooth_atlas, 0, sizeof slot->smooth_atlas); + Aeron_LogWarn("xwa.remaster", "frontend font %d: optional smooth atlas unavailable", font_size); + } Xwa2dFontAtlas_Free(&font); return ASSET_LOAD_SUCCESS; } @@ -1659,10 +1675,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; + *out_atlas_scale = (float)XWA_ORIGINAL_FONT_SCALE; + return slot->atlas.loaded ? &slot->atlas : NULL; } } return NULL; diff --git a/src/xwa_remaster/assets.h b/src/xwa_remaster/assets.h index 5470092c..c36c3add 100644 --- a/src/xwa_remaster/assets.h +++ b/src/xwa_remaster/assets.h @@ -64,8 +64,10 @@ typedef struct XwaAssetRef { /* `root` is the mod folder. `prefer_original_2d` changes * source order; the alternate source is selected only when the first is absent. */ -XwaRemasterAssets* XwaRemasterAssets_Create(const char* root, int prefer_original_2d); +XwaRemasterAssets* XwaRemasterAssets_Create(const char* root, int prefer_original_2d, int smooth_menu_fonts); 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 @@ -114,7 +116,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/xwa_remaster.c b/src/xwa_remaster/xwa_remaster.c index f6c85cc0..ed21b747 100644 --- a/src/xwa_remaster/xwa_remaster.c +++ b/src/xwa_remaster/xwa_remaster.c @@ -91,6 +91,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 @@ -246,7 +247,8 @@ static int XwaRemaster_VideoOptionsValid(const XwaModernVideoOptions* options) { options->motion_blur_amount <= 1.0f && options->sdr_gamma >= XWA_MODERN_SDR_GAMMA_2_2 && options->sdr_gamma <= XWA_MODERN_SDR_GAMMA_SRGB && options->paper_white >= XWA_MODERN_PAPER_WHITE_AUTO && - options->paper_white <= XWA_MODERN_PAPER_WHITE_400; + options->paper_white <= XWA_MODERN_PAPER_WHITE_400 && + (options->smooth_menu_fonts == 0 || options->smooth_menu_fonts == 1); } void XwaRemaster_GetVideoOptions(XwaModernVideoOptions* out) { @@ -276,6 +278,7 @@ void XwaRemaster_GetVideoOptions(XwaModernVideoOptions* out) { out->hdr_output = XwaRemaster_GetHdrDesired(); out->sdr_gamma = XwaRemaster_SdrGammaFromDecode(Aeron_OutputSdrContentGamma()); out->paper_white = XwaRemaster_PaperWhiteFromNits(Aeron_OutputPaperWhiteNits()); + out->smooth_menu_fonts = g.smooth_menu_fonts; } void XwaRemaster_SetVideoOptions(const XwaModernVideoOptions* options) { @@ -318,6 +321,8 @@ void XwaRemaster_SetVideoOptions(const XwaModernVideoOptions* options) { #endif XwaRemaster_SetHdrDesired(options->hdr_output); + g.smooth_menu_fonts = options->smooth_menu_fonts; + XwaRemasterAssets_SetSmoothMenuFonts(g.assets, g.smooth_menu_fonts); } int XwaRemaster_Init(const XwaRemasterInitOptions* options) { @@ -388,6 +393,9 @@ int XwaRemaster_Init(const XwaRemasterInitOptions* options) { if (video_override_mask & XWA_MODERN_VIDEO_OVERRIDE_PAPER_WHITE) { video_options.paper_white = options->video_options.paper_white; } + 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 @@ -399,7 +407,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); + g.assets = XwaRemasterAssets_Create(Aeron_ModRoot(), options->prefer_original_2d, 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_video_options.c b/src/xwa_runtime/config/modern_video_options.c index 7706c94d..dff3fef0 100644 --- a/src/xwa_runtime/config/modern_video_options.c +++ b/src/xwa_runtime/config/modern_video_options.c @@ -29,7 +29,8 @@ static int XwaModernVideoOptions_IsValid(const XwaModernVideoOptions* options) { options->motion_blur_amount <= 1.0f && options->sdr_gamma >= XWA_MODERN_SDR_GAMMA_2_2 && options->sdr_gamma <= XWA_MODERN_SDR_GAMMA_SRGB && options->paper_white >= XWA_MODERN_PAPER_WHITE_AUTO && - options->paper_white <= XWA_MODERN_PAPER_WHITE_400; + options->paper_white <= XWA_MODERN_PAPER_WHITE_400 && + (options->smooth_menu_fonts == 0 || options->smooth_menu_fonts == 1); } static int XwaModernVideoOptions_AreEqual(const XwaModernVideoOptions* lhs, @@ -38,7 +39,8 @@ static int XwaModernVideoOptions_AreEqual(const XwaModernVideoOptions* lhs, lhs->shadow_quality == rhs->shadow_quality && lhs->fsr_upscaling == rhs->fsr_upscaling && 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->sdr_gamma == rhs->sdr_gamma && lhs->paper_white == rhs->paper_white && + 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 8634a3a5..ab4ac948 100644 --- a/src/xwa_runtime/config/modern_video_options.h +++ b/src/xwa_runtime/config/modern_video_options.h @@ -77,6 +77,7 @@ typedef struct XwaModernVideoOptions { int hdr_output; XwaModernSdrGamma sdr_gamma; XwaModernPaperWhite paper_white; + int smooth_menu_fonts; /* original frontend bitmap fonts only */ } XwaModernVideoOptions; enum { @@ -90,6 +91,7 @@ enum { XWA_MODERN_VIDEO_OVERRIDE_PAPER_WHITE = 1u << 7, XWA_MODERN_VIDEO_OVERRIDE_MOTION_BLUR_AMOUNT = 1u << 8, XWA_MODERN_VIDEO_OVERRIDE_SHADOW_QUALITY = 1u << 9, + XWA_MODERN_VIDEO_OVERRIDE_SMOOTH_MENU_FONTS = 1u << 10, }; 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 a25c12f9..09d91cfa 100644 --- a/src/xwa_runtime/config/modern_video_options_screen.c +++ b/src/xwa_runtime/config/modern_video_options_screen.c @@ -38,6 +38,7 @@ int XwaModernVideoOptionsScreen_Update(int menu_center_x, int* cursor_row) { uint8_t hdr; uint8_t sdr_gamma; uint8_t paper_white; + uint8_t smooth_menu_fonts; uint8_t enhanced_camera; int hdr_rows_disabled; int motion_blur_amount_changed; @@ -61,10 +62,11 @@ int XwaModernVideoOptionsScreen_Update(int menu_center_x, int* cursor_row) { hdr = (uint8_t)(options.hdr_output != 0); sdr_gamma = (uint8_t)options.sdr_gamma; paper_white = (uint8_t)options.paper_white; + smooth_menu_fonts = (uint8_t)options.smooth_menu_fonts; XwaModernCameraOptions_Get(&camera_options); enhanced_camera = (uint8_t)(camera_options.enabled != 0); changed = 0; - XwaModernOptionsMenu_Begin(&menu, menu_center_x, 140, cursor_row, 12); + XwaModernOptionsMenu_Begin(&menu, menu_center_x, 140, cursor_row, 13); XwaModernOptionsMenu_DrawTitle(&menu, "OpenXWA Video Options"); changed |= @@ -74,6 +76,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, 71, 0); changed |= XwaModernOptionsMenu_DrawCycleU8(&menu, &motion_blur_quality, "Motion Blur", motion_blur_texts, 3, 65, 0); motion_blur_amount_changed = @@ -118,6 +122,7 @@ int XwaModernVideoOptionsScreen_Update(int menu_center_x, int* cursor_row) { options.hdr_output = hdr != 0; options.sdr_gamma = (XwaModernSdrGamma)sdr_gamma; options.paper_white = (XwaModernPaperWhite)paper_white; + options.smooth_menu_fonts = smooth_menu_fonts; XwaModernVideoOptions_Set(&options); } /* The exterior camera lag and tilt, the cockpit sway and the exterior HUD. From 2fb881ffbd98e56acd00d623360e28abb46b3888 Mon Sep 17 00:00:00 2001 From: Rasi Talon Date: Tue, 3 Nov 2026 08:51:29 +0200 Subject: [PATCH 02/10] TrackIR support --- CMakeLists.txt | 11 + resources/remaster/config.yaml | 13 + src/xwa/config/game_config.c | 12 + src/xwa/flight/flight_view.c | 159 ++++++++++++ src/xwa/flight/hangar.c | 26 ++ src/xwa/flight/hud/hud.c | 59 ++++- src/xwa/render/render_scene_core.c | 35 +++ src/xwa/render/renderer.h | 1 + src/xwa_app/host_config.c | 60 ++++- src/xwa_remaster/flight.c | 17 +- src/xwa_remaster/hud_fixed.c | 19 +- .../config/modern_head_tracking_screen.c | 81 ++++++ .../config/modern_head_tracking_screen.h | 15 ++ src/xwa_runtime/config/modern_input_options.c | 16 ++ src/xwa_runtime/config/modern_input_options.h | 21 ++ .../config/modern_input_options_screen.c | 5 +- src/xwa_runtime/input/opentrack_udp.c | 178 +++++++++++++ src/xwa_runtime/input/opentrack_udp.h | 13 + src/xwa_runtime/input/trackir.c | 241 ++++++++++++++++++ src/xwa_runtime/input/trackir.h | 27 ++ src/xwa_runtime/runtime/flight_task.c | 2 + src/xwa_runtime/snapshot/snapshot.c | 14 +- src/xwa_runtime/snapshot/snapshot.h | 5 + src/xwa_runtime/snapshot/snapshot_hud.c | 13 + 24 files changed, 1020 insertions(+), 23 deletions(-) create mode 100644 src/xwa_runtime/config/modern_head_tracking_screen.c create mode 100644 src/xwa_runtime/config/modern_head_tracking_screen.h create mode 100644 src/xwa_runtime/input/opentrack_udp.c create mode 100644 src/xwa_runtime/input/opentrack_udp.h create mode 100644 src/xwa_runtime/input/trackir.c create mode 100644 src/xwa_runtime/input/trackir.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 5c4aff9b..a045a261 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 @@ -276,6 +277,8 @@ 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/trackir.c + src/xwa_runtime/input/opentrack_udp.c src/xwa_runtime/runtime/flight_pause_task.c src/xwa_runtime/runtime/flight_task.c src/xwa_runtime/runtime/frontend_task.c @@ -307,6 +310,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) + # NPClient is loaded at runtime from the existing TrackIR installation. + # Only the Windows registry API is linked; no TrackIR SDK is bundled. + 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 @@ -729,6 +737,7 @@ set(XWA_FORMAT_HEADERS src/xwa_runtime/config/modern_camera_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 @@ -835,6 +844,8 @@ 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/trackir.h + src/xwa_runtime/input/opentrack_udp.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 9c1434ba..23c39dd0 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. diff --git a/src/xwa/config/game_config.c b/src/xwa/config/game_config.c index 45155f55..66bbd630 100644 --- a/src/xwa/config/game_config.c +++ b/src/xwa/config/game_config.c @@ -40,6 +40,7 @@ #ifdef XWA_MODERN #include "xwa_runtime/config/modern_camera_options.h" #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" @@ -944,6 +945,10 @@ int Config_ControllerOptionsScreen(void) { g_pendingMenuScreen = 20; g_menuCursorRow = 0; break; + case 3: + g_pendingMenuScreen = 24; + g_menuCursorRow = 0; + break; default: break; } @@ -2614,6 +2619,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; diff --git a/src/xwa/flight/flight_view.c b/src/xwa/flight/flight_view.c index 54b8cf05..380f9f7c 100644 --- a/src/xwa/flight/flight_view.c +++ b/src/xwa/flight/flight_view.c @@ -15,6 +15,8 @@ #include "xwa/util/time.h" #ifdef XWA_MODERN #include "aeron/log.h" +#include "aeron/asset/opt_model.h" +#include "xwa_runtime/input/trackir.h" #include "xwa/flight/flight_debug.h" #include "xwa_runtime/camera/camera_motion.h" #include "xwa_runtime/hooks/orientation_hook.h" @@ -280,6 +282,105 @@ static void FVIEW_StoreCamShadow(const double m[9]) { g_camShadowValid = 1; FVIEW_CopyShadowToViewMtx(); } +/* TrackIR rolls 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_TrackIRToQ15(double value) { + long scaled = lround(value * 32768.0); + if (scaled < -32767) return -32767; + if (scaled > 32767) return 32767; + return (int)scaled; +} + +static void FVIEW_ApplyTrackIRRoll(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]; + } + g_camMatR0_X = g_curMatR0_X = FVIEW_TrackIRToQ15(rows[0]); + g_camMatR0_Y = g_curMatR0_Y = FVIEW_TrackIRToQ15(rows[1]); + g_camMatR0_Z = g_curMatR0_Z = FVIEW_TrackIRToQ15(rows[2]); + g_camMatR1_X = g_curMatR1_X = FVIEW_TrackIRToQ15(rows[3]); + g_camMatR1_Y = g_curMatR1_Y = FVIEW_TrackIRToQ15(rows[4]); + g_camMatR1_Z = g_curMatR1_Z = FVIEW_TrackIRToQ15(rows[5]); + FVIEW_StoreCamShadow(rows); +} + +/* 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_ApplyTrackIRLookToCamera(const XwaTrackIRPose* 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); + g_camMatR0_X = g_curMatR0_X = FVIEW_TrackIRToQ15(rows[0]); + g_camMatR0_Y = g_curMatR0_Y = FVIEW_TrackIRToQ15(rows[1]); + g_camMatR0_Z = g_curMatR0_Z = FVIEW_TrackIRToQ15(rows[2]); + g_camMatR1_X = g_curMatR1_X = FVIEW_TrackIRToQ15(rows[3]); + g_camMatR1_Y = g_curMatR1_Y = FVIEW_TrackIRToQ15(rows[4]); + g_camMatR1_Z = g_curMatR1_Z = FVIEW_TrackIRToQ15(rows[5]); + g_camMatR2_X = g_curMatR2_X = FVIEW_TrackIRToQ15(rows[6]); + g_camMatR2_Y = g_curMatR2_Y = FVIEW_TrackIRToQ15(rows[7]); + g_camMatR2_Z = g_curMatR2_Z = FVIEW_TrackIRToQ15(rows[8]); + FVIEW_StoreCamShadow(rows); + FVIEW_ApplyTrackIRRoll(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_ApplyTrackIRTranslation(int playerIdx, const XwaTrackIRPose* 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]; + int* const cameraCoord[3] = { + &g_players[playerIdx].viewState.savedTargetX, + &g_players[playerIdx].viewState.savedTargetY, + &g_players[playerIdx].viewState.savedTargetZ + }; + for (int axis = 0; axis < 3; ++axis) { + const float relative = local[0] * baseRows[axis] + + local[1] * baseRows[3 + axis] + local[2] * baseRows[6 + axis]; + worldOffset[axis] = relative * scale; + *cameraCoord[axis] += (int)lroundf(worldOffset[axis]); + } + XwaTrackIR_SetCameraOffset(worldOffset); +} + +/* Overlay local head movement on a camera that already established its base pose. */ +void FlightView_ApplyTrackIRToCurrentCamera(int playerIdx) { + XwaTrackIRPose head; + float baseRows[9]; + if (playerIdx != g_localPlayer || !XwaTrackIR_Poll(&head)) return; + FVIEW_CopyRenderCameraRows(baseRows); + FVIEW_ApplyTrackIRLookToCamera(&head); + FVIEW_ApplyTrackIRTranslation(playerIdx, &head, baseRows); +} + #endif // FUNCTION: XWA 0x497610 @@ -908,6 +1009,8 @@ 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) XwaTrackIR_ClearPose(); #endif if (g_players[playerIdx].mapCameraState) { FlightMap_UpdateCamera(playerIdx); @@ -1142,6 +1245,22 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { } } } else { +#ifdef XWA_MODERN + XwaTrackIRPose trackir = {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. */ + if (playerIdx == g_localPlayer && !g_filmRecording && !g_filmPlaybackMode && + g_players[playerIdx].hyperspacePhase == PLAYER_HYPERSPACE_PHASE_NONE && + 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 = XwaTrackIR_Poll(&trackir); + } +#endif g_players[playerIdx].viewState.viewRoll = g_objectTable[cameraFocusObjIdx].roll; g_players[playerIdx].viewState.viewPitch = g_objectTable[g_players[playerIdx].viewState.cameraFocusObjIdx].pitch; @@ -1149,6 +1268,19 @@ 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); @@ -1161,9 +1293,17 @@ 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 ? trackir.pitch_q16 : 0)), + (int16_t)(g_players[playerIdx].viewState.hudAimY + (tracking ? trackir.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_ApplyTrackIRRoll(trackir.roll_q16); +#endif g_players[playerIdx].viewState.savedTargetX = g_objectTable[g_players[playerIdx].viewState.cameraFocusObjIdx].world_x; g_players[playerIdx].viewState.savedTargetY = @@ -1182,6 +1322,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_ApplyTrackIRTranslation(playerIdx, &trackir, seatRows); + } +#endif } } @@ -1210,6 +1355,20 @@ 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 && + (g_players[playerIdx].hyperspacePhase != PLAYER_HYPERSPACE_PHASE_NONE || + (g_players[playerIdx].viewState.externalCameraActive && + !g_players[playerIdx].viewState.playerInputBlocked))) { + FlightView_ApplyTrackIRToCurrentCamera(playerIdx); + } +#endif } // FUNCTION: XWA 0x4F1B00 diff --git a/src/xwa/flight/hangar.c b/src/xwa/flight/hangar.c index 473cb058..e1020675 100644 --- a/src/xwa/flight/hangar.c +++ b/src/xwa/flight/hangar.c @@ -56,6 +56,7 @@ #include "xwa_runtime/models/warhead_launchers.h" #include "xwa_runtime/models/warhead_pools.h" #include "xwa_runtime/models/weapon_stats.h" +#include "xwa_runtime/input/trackir.h" #include "xwa_runtime/snapshot/snapshot_hud.h" #include "xwa_runtime/timing/modern_flight_timing.h" #endif @@ -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 @@ -2844,7 +2847,25 @@ void Hangar_RenderReadyScreen(void) { Mission_SetActiveRegionObjectRanges(player->regionIndex); if (g_hangarAutoCam) { +#ifdef XWA_MODERN + XwaTrackIR_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_ApplyTrackIRToCurrentCamera(g_localPlayer); + } + } +#endif } else { FlightView_UpdatePlayerCamera(g_localPlayer); } @@ -4979,6 +5000,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 e2bdd0f1..67475fd1 100644 --- a/src/xwa/flight/hud/hud.c +++ b/src/xwa/flight/hud/hud.c @@ -43,6 +43,7 @@ #include "xwa_runtime/models/warhead_lock.h" #include "xwa_runtime/models/weapon_stats.h" #include "xwa_runtime/snapshot/snapshot_hud.h" +#include "xwa_runtime/input/trackir.h" #endif #include "xwa/render/effects.h" #include "xwa/render/renderer_internal.h" @@ -3450,6 +3451,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 (XwaTrackIR_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 * @@ -6924,6 +6936,18 @@ void Hud_DrawReticle3D(void) { uint8_t inRange; uint16_t lockRange; unsigned int lockRangeDivisor; + int16_t effectiveLookYaw = g_players[g_localPlayer].lookYawOffset; + int16_t effectiveLookPitch = g_players[g_localPlayer].lookPitchOffset; + int projectReticle = HUD_RETICLE_PROJECTED(); +#ifdef XWA_MODERN + XwaTrackIRPose head; + const int trackirLook = XwaTrackIR_CurrentPose(&head) && (head.yaw_q16 || head.pitch_q16); + if (trackirLook) { + effectiveLookYaw = (int16_t)(effectiveLookYaw + head.yaw_q16); + effectiveLookPitch = (int16_t)(effectiveLookPitch + head.pitch_q16); + projectReticle = 1; + } +#endif g_reticleDrawX = g_reticleCenterX; g_reticleDrawY = g_reticleCenterY; @@ -6966,22 +6990,31 @@ void Hud_DrawReticle3D(void) { #endif lockRangeDivisor = lockRange / 200; - if (Hud_AbsLookDegreesFromOffset(g_players[g_localPlayer].lookYawOffset) >= 45 || - Hud_AbsLookDegreesFromOffset(g_players[g_localPlayer].lookPitchOffset) >= 45) { + if (Hud_AbsLookDegreesFromOffset(effectiveLookYaw) >= 45 || + Hud_AbsLookDegreesFromOffset(effectiveLookPitch) >= 45) { return; } - if (HUD_RETICLE_PROJECTED()) { + if (projectReticle) { int worldZ; - - pai_RotateLocalVectorToWorldScratch(&g_objectTable[g_players[g_localPlayer].objectIndex], 0, 0, +#ifdef XWA_MODERN + if (trackirLook && 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; + 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); @@ -7032,7 +7065,7 @@ void Hud_DrawReticle3D(void) { } } - if (HUD_RETICLE_PROJECTED()) { + if (projectReticle) { int aimX; int drawX; @@ -7114,7 +7147,7 @@ void Hud_DrawReticle3D(void) { quad.screenX = g_reticleCenterX; quad.screenY = g_screenHeight - g_reticleCenterY; - if (HUD_RETICLE_PROJECTED()) { + if (projectReticle) { quad.screenX = g_reticleDrawX; quad.screenY = g_screenHeight - g_reticleDrawY; } diff --git a/src/xwa/render/render_scene_core.c b/src/xwa/render/render_scene_core.c index ae504a7d..635fd8d1 100644 --- a/src/xwa/render/render_scene_core.c +++ b/src/xwa/render/render_scene_core.c @@ -9,6 +9,8 @@ #include "xwa_runtime/models/articulation.h" #include "xwa_runtime/models/gunner_seats.h" #include "xwa_runtime/models/opt_synth.h" +#include "xwa_runtime/input/trackir.h" +#include #endif #ifndef XWA_MODERN @@ -37,6 +39,27 @@ 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 TrackIR 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_ApplyTrackIRTranslation(Vec3f* pos, int cockpit_active) { + float offset[3]; + if (!pos) return; + XwaTrackIR_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; } @@ -415,6 +438,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_ApplyTrackIRTranslation(&mesh.viewPos, g_cockpitViewActive); +#endif mesh.viewOrient[0] = g_viewMtx00; mesh.viewOrient[1] = g_viewMtx10; @@ -807,6 +834,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_ApplyTrackIRTranslation(&mesh.viewPos, g_cockpitViewActive); +#endif viewMatrix.m[0] = g_viewMtx00; viewMatrix.m[1] = g_viewMtx10; @@ -1054,6 +1085,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_ApplyTrackIRTranslation(&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 45270095..b440de1b 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_ApplyTrackIRToCurrentCamera(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 2b51145c..1c491966 100644 --- a/src/xwa_app/host_config.c +++ b/src/xwa_app/host_config.c @@ -1031,6 +1031,41 @@ static int host_config_controller_options(const AeronConfigFile* config, int req 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; + } + 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; @@ -1042,7 +1077,8 @@ 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, &out->controller, error, error_size) || + !host_config_head_tracking(config, required, &out->head_tracking, error, error_size)) { return 0; } @@ -1071,6 +1107,8 @@ static int host_config_input_options(const AeronConfigFile* config, int required 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", @@ -1523,6 +1561,25 @@ static int host_config_set_controller_profile(AeronConfigFile* document, const c return host_config_set_controller_actions(document, profile_name, profile->actions, error); } +static int host_config_set_head_tracking(AeronConfigFile* document, const XwaHeadTrackingOptions* head, + AeronConfigError* error) { + 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_input_options(AeronConfigFile* document, const XwaModernInputOptions* options, AeronConfigError* error) { static const char* const mode_names[] = { "position", "rate" }; @@ -1534,6 +1591,7 @@ static int host_config_set_input_options(AeronConfigFile* document, const XwaMod 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) && AeronConfigFile_SetString(document, "input.controller.device.guid", options->controller.device.guid, error) && AeronConfigFile_SetString(document, "input.controller.device.path", diff --git a/src/xwa_remaster/flight.c b/src/xwa_remaster/flight.c index 74b53c23..013e7b08 100644 --- a/src/xwa_remaster/flight.c +++ b/src/xwa_remaster/flight.c @@ -2329,7 +2329,9 @@ 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->trackir_head_offset, prev_cockpit->trackir_head_offset, + sizeof cockpit->trackir_head_offset) != 0; } /* Rotate inverted gunner cockpits about the model-local X axis, @@ -2344,7 +2346,7 @@ 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 - + position[axis] = camera_local[axis] - cockpit->trackir_head_offset[axis] - cockpit->camera_pan[axis] * 0.0625f - sway[axis] - rotated_eye; } } @@ -2376,7 +2378,10 @@ 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 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->trackir_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) { @@ -4496,7 +4501,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] + + /* 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.trackir_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) { diff --git a/src/xwa_remaster/hud_fixed.c b/src/xwa_remaster/hud_fixed.c index d04249b6..5ff1bce3 100644 --- a/src/xwa_remaster/hud_fixed.c +++ b/src/xwa_remaster/hud_fixed.c @@ -854,9 +854,22 @@ 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 actual pre-TrackIR aim direction 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_runtime/config/modern_head_tracking_screen.c b/src/xwa_runtime/config/modern_head_tracking_screen.c new file mode 100644 index 00000000..f481fc7a --- /dev/null +++ b/src/xwa_runtime/config/modern_head_tracking_screen.c @@ -0,0 +1,81 @@ +#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/trackir.h" + +#include + +int XwaModernHeadTrackingScreen_Update(int menu_center_x, int* cursor_row) { + 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); + } + /* One master switch, one source, six inversion controls, and Back. */ + XwaModernOptionsMenu_Begin(&menu, menu_center_x, 120, cursor_row, 9); + 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. */ + XwaTrackIR_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..f6ddc27a 100644 --- a/src/xwa_runtime/config/modern_input_options.c +++ b/src/xwa_runtime/config/modern_input_options.c @@ -112,6 +112,8 @@ int XwaModernInputOptions_Validate(const XwaModernInputOptions* options) { 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->head_tracking.source != XWA_HEAD_TRACK_SOURCE_TRACKIR && + options->head_tracking.source != XWA_HEAD_TRACK_SOURCE_OPENTRACK_UDP) || 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)) || @@ -145,6 +147,16 @@ static void XwaModernInputOptions_Normalize(XwaModernInputOptions* options) { 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->head_tracking.enabled = options->head_tracking.enabled != 0; +#ifndef _WIN32 + /* The shipped defaults name native TrackIR for Windows. On other hosts, + * normalize the effective selection to the only available UDP provider, + * even when the user enables tracking without opening the settings menu. */ + 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; + } options->controller.gamepad.pov_source = options->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; @@ -204,6 +216,10 @@ 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 && + 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); } diff --git a/src/xwa_runtime/config/modern_input_options.h b/src/xwa_runtime/config/modern_input_options.h index 5ab25687..b113ea05 100644 --- a/src/xwa_runtime/config/modern_input_options.h +++ b/src/xwa_runtime/config/modern_input_options.h @@ -67,6 +67,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 runtime TrackIR ABI, + * 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 +96,7 @@ typedef struct XwaModernInputOptions { int mouse_sensitivity; int mouse_invert_y; XwaControllerOptions controller; + XwaHeadTrackingOptions head_tracking; } XwaModernInputOptions; typedef void (*XwaModernInputOptionsApplyFn)(const XwaModernInputOptions* options); diff --git a/src/xwa_runtime/config/modern_input_options_screen.c b/src/xwa_runtime/config/modern_input_options_screen.c index 700bc00d..32eb4ab3 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, 145, cursor_row, 7); XwaModernOptionsMenu_DrawTitle(&menu, FrontendString_Get(STR_CONFIG_GAME_CONTROLLER_OPTIONS)); changed |= XwaModernOptionsMenu_DrawCycleU8(&menu, &mouse_flight, "Mouse Flight Control", toggle_texts, 2, @@ -56,6 +56,9 @@ int XwaModernInputOptionsScreen_Update(int menu_center_x, int* cursor_row) { if (XwaModernOptionsMenu_DrawAction(&menu, "Controller Setup", 75, 0)) { result = 2; } + 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/input/opentrack_udp.c b/src/xwa_runtime/input/opentrack_udp.c new file mode 100644 index 00000000..0a7808be --- /dev/null +++ b/src/xwa_runtime/input/opentrack_udp.c @@ -0,0 +1,178 @@ +/* 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 XwaTrackIRPose 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, XwaTrackIRPose* 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(XwaTrackIRPose* pose, const XwaHeadTrackingOptions* options) { + uint32_t now = XwaTime_GetElapsedTicks(); + unsigned char packet[49]; /* One extra byte detects oversized datagrams. */ + XwaTrackIRPose 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..88835d10 --- /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/trackir.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(XwaTrackIRPose* 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..63781d9b --- /dev/null +++ b/src/xwa_runtime/input/trackir.c @@ -0,0 +1,241 @@ +/* TrackIR runtime bridge. Uses the NPClient64.dll installed by TrackIR. + * No SDK headers, code, or DLLs are distributed with OpenXWA. */ +#include "xwa_runtime/input/trackir.h" +#include "xwa_runtime/input/opentrack_udp.h" +#include "xwa_runtime/config/modern_input_options.h" + +#include +#include + +/* Stored separately from recovered simulation/player state. */ +static XwaTrackIRPose s_pose; +static int s_pose_valid; +static float s_camera_offset[3]; + +void XwaTrackIR_ClearPose(void) { + memset(&s_pose, 0, sizeof s_pose); + memset(s_camera_offset, 0, sizeof s_camera_offset); + s_pose_valid = 0; +} + +void XwaTrackIR_SetCameraOffset(const float world_offset[3]) { + if (world_offset && s_pose_valid) { + memcpy(s_camera_offset, world_offset, sizeof s_camera_offset); + } +} + +void XwaTrackIR_GetCameraOffset(float world_offset[3]) { + if (world_offset) { + memcpy(world_offset, s_camera_offset, sizeof s_camera_offset); + } +} + +int XwaTrackIR_CurrentPose(XwaTrackIRPose* out) { + if (out) *out = s_pose; + return s_pose_valid; +} + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#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) { + XwaOpenTrackUdp_Shutdown(); + if (s_module) { + if (s_stop) s_stop(); + if (s_unregister) s_unregister(); + FreeLibrary(s_module); + } + XwaTrackIR_ClearPose(); + 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) { + XwaTrackIR_Shutdown(); + return 0; + } + + /* 1000 is NaturalPoint's public SDK/developer test profile. */ + if (reg_window(hwnd) != 0) { + XwaTrackIR_Shutdown(); + return 0; + } + s_window = hwnd; + if (reg_profile(1000) != 0 || request(1 | 2 | 4 | 16 | 32 | 64) != 0 || + start() != 0) { + XwaTrackIR_Shutdown(); + return 0; + } + return 1; +} + +int XwaTrackIR_Poll(XwaTrackIRPose* out) { + TrackIRRawFrame frame; + XwaModernInputOptions options; + ULONGLONG now = GetTickCount64(); + if (!out) return 0; + memset(out, 0, sizeof *out); + XwaTrackIR_ClearPose(); + XwaModernInputOptions_Get(&options); + if (options.head_tracking.enabled && + options.head_tracking.source == XWA_HEAD_TRACK_SOURCE_OPENTRACK_UDP) { + if (s_module) XwaTrackIR_Shutdown(); /* Release native NPClient on source change. */ + if (XwaOpenTrackUdp_Poll(out, &options.head_tracking)) { + s_pose = *out; + s_pose_valid = 1; + return 1; + } + return 0; + } + XwaOpenTrackUdp_Shutdown(); + if (!options.head_tracking.enabled || + options.head_tracking.source != XWA_HEAD_TRACK_SOURCE_TRACKIR) { + /* Disabled means no DLL connection, polling, or residual pose. */ + if (s_module) XwaTrackIR_Shutdown(); + return 0; + } + + if (!s_module) { + if (now < s_next_attempt) return 0; + s_next_attempt = now + 3000; + if (!TrackIR_Connect()) { + /* Failed registration may call Shutdown(), which clears the + * retry 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.head_tracking.invert[XWA_HEAD_TRACK_AXIS_YAW]); + out->pitch_q16 = angle_q16(frame.pitch, !options.head_tracking.invert[XWA_HEAD_TRACK_AXIS_PITCH]); + out->roll_q16 = angle_q16(frame.roll, !options.head_tracking.invert[XWA_HEAD_TRACK_AXIS_ROLL]); + out->left_cm = frame.x * (50.0f / 16383.0f) * + (options.head_tracking.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 * (50.0f / 16383.0f) * + (options.head_tracking.invert[XWA_HEAD_TRACK_AXIS_Y] ? -1.0f : 1.0f); + out->back_cm = frame.z * (50.0f / 16383.0f) * + (options.head_tracking.invert[XWA_HEAD_TRACK_AXIS_Z] ? -1.0f : 1.0f); + s_pose = *out; + s_pose_valid = 1; + return 1; +} + +#else + +int XwaTrackIR_Poll(XwaTrackIRPose* out) { + XwaModernInputOptions options; + if (!out) return 0; + memset(out, 0, sizeof *out); + XwaTrackIR_ClearPose(); + XwaModernInputOptions_Get(&options); + if (options.head_tracking.enabled && + options.head_tracking.source == XWA_HEAD_TRACK_SOURCE_OPENTRACK_UDP) { + if (XwaOpenTrackUdp_Poll(out, &options.head_tracking)) { + s_pose = *out; + s_pose_valid = 1; + return 1; + } + return 0; + } + XwaOpenTrackUdp_Shutdown(); + return 0; +} + +void XwaTrackIR_Shutdown(void) { + XwaOpenTrackUdp_Shutdown(); + XwaTrackIR_ClearPose(); +} + +#endif diff --git a/src/xwa_runtime/input/trackir.h b/src/xwa_runtime/input/trackir.h new file mode 100644 index 00000000..ee376de6 --- /dev/null +++ b/src/xwa_runtime/input/trackir.h @@ -0,0 +1,27 @@ +#ifndef XWA_RUNTIME_TRACKIR_H +#define XWA_RUNTIME_TRACKIR_H + +#include + +/* Optional, Windows-only head pose. No NaturalPoint SDK files are included. */ +typedef struct XwaTrackIRPose { + 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; +} XwaTrackIRPose; + +/* Returns zero if unavailable, unfocused, paused, or data is stale. */ +int XwaTrackIR_Poll(XwaTrackIRPose* out); +/* Last pose sampled for the current flight frame; zero when tracking is unavailable. */ +int XwaTrackIR_CurrentPose(XwaTrackIRPose* out); +void XwaTrackIR_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 XwaTrackIR_SetCameraOffset(const float world_offset[3]); +void XwaTrackIR_GetCameraOffset(float world_offset[3]); +void XwaTrackIR_Shutdown(void); + +#endif diff --git a/src/xwa_runtime/runtime/flight_task.c b/src/xwa_runtime/runtime/flight_task.c index d2db6ff7..dc8dd630 100644 --- a/src/xwa_runtime/runtime/flight_task.c +++ b/src/xwa_runtime/runtime/flight_task.c @@ -1,3 +1,4 @@ +#include "xwa_runtime/input/trackir.h" #include "xwa_runtime/runtime/flight_task.h" #include "xwa/flight/hangar.h" #include "xwa_runtime/runtime/film_benchmark.h" @@ -1951,6 +1952,7 @@ void XwaFlightTask_Tick(void) { } int XwaFlightTask_Shutdown(void) { + XwaTrackIR_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 9be5ee81..6cff68e3 100644 --- a/src/xwa_runtime/snapshot/snapshot.c +++ b/src/xwa_runtime/snapshot/snapshot.c @@ -24,6 +24,7 @@ #include "xwa_runtime/models/skins.h" #include "xwa_runtime/models/surfaces.h" #include "xwa_runtime/snapshot/snapshot_camera.h" +#include "xwa_runtime/input/trackir.h" #include "xwa_runtime/snapshot/snapshot_flight_map.h" #include "xwa_runtime/snapshot/snapshot_hud.h" #include "xwa_runtime/timing/host_clock.h" @@ -1838,7 +1839,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 trackir_world_offset[3] = {0}; + int32_t untracked_pos[3]; + XwaTrackIR_GetCameraOffset(trackir_world_offset); + for (int axis = 0; axis < 3; ++axis) { + /* Lookup the existing camera fraction using its pre-TrackIR position. */ + untracked_pos[axis] = c->world_pos[axis] - (int32_t)lroundf(trackir_world_offset[axis]); + } + XwaSnapshotCamera_PositionFraction(untracked_pos, c->world_pos_fraction); + for (int axis = 0; axis < 3; ++axis) { + c->world_pos_fraction[axis] += trackir_world_offset[axis] - roundf(trackir_world_offset[axis]); + } FVIEW_CopyRenderCameraRows(c->rows); c->view_pitch = vs->viewPitch; c->view_yaw = vs->viewYaw; @@ -1970,6 +1981,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; + XwaTrackIR_GetCameraOffset(k->trackir_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 4318afd2..16446687 100644 --- a/src/xwa_runtime/snapshot/snapshot.h +++ b/src/xwa_runtime/snapshot/snapshot.h @@ -686,6 +686,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 trackir_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 classic anchors the weapon-fire pulse point @@ -1069,6 +1070,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 TrackIR 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 3e9297a6..a5908329 100644 --- a/src/xwa_runtime/snapshot/snapshot_hud.c +++ b/src/xwa_runtime/snapshot/snapshot_hud.c @@ -1,3 +1,4 @@ +#include "xwa_runtime/input/trackir.h" #include "xwa_runtime/snapshot/snapshot_hud.h" #include "xwa/assets/flight_model.h" @@ -475,7 +476,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; + XwaTrackIRPose head; + const int trackirLook = XwaTrackIR_CurrentPose(&head) && (head.yaw_q16 || head.pitch_q16); + if (trackirLook) { + 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)(trackirLook && 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; From 6de1958956d9ddf615c2414dc30f4bfc57298302 Mon Sep 17 00:00:00 2001 From: Rasi Talon Date: Sat, 7 Nov 2026 16:39:14 +0200 Subject: [PATCH 03/10] Tidy the smooth menu fonts change Keep XwaRemasterAssets_Create's signature and set the option right after creating the assets. Drop the cleanup on a failed 1x atlas, which AeronFontAtlas_InitRgba8 leaves empty, and leave the 4x scale as it was. --- src/xwa_remaster/assets.c | 11 ++++------- src/xwa_remaster/assets.h | 2 +- src/xwa_remaster/xwa_remaster.c | 3 ++- src/xwa_runtime/config/modern_video_options_screen.c | 4 ++-- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/xwa_remaster/assets.c b/src/xwa_remaster/assets.c index d4764776..c56135f1 100644 --- a/src/xwa_remaster/assets.c +++ b/src/xwa_remaster/assets.c @@ -167,14 +167,13 @@ struct XwaRemasterAssets { uint32_t frame_set_updates; /* counts XwaRemasterAssets_UpdateFrameSets calls */ }; -XwaRemasterAssets* XwaRemasterAssets_Create(const char* root, int prefer_original_2d, int smooth_menu_fonts) { +XwaRemasterAssets* XwaRemasterAssets_Create(const char* root, int prefer_original_2d) { XwaRemasterAssets* a = (XwaRemasterAssets*)calloc(1, sizeof *a); if (!a) { return NULL; } snprintf(a->root, sizeof a->root, "%s", root ? root : ""); a->prefer_original_2d = prefer_original_2d != 0; - a->smooth_menu_fonts = smooth_menu_fonts != 0; a->mission_index = -1; /* The frame sets use their own random numbers, so they don't change the game's rand() sequence. */ @@ -1517,11 +1516,9 @@ static AssetLoadStatus assets_load_original_frontend_font(XwaRemasterAssets* a, Xwa2dFontAtlas_Free(&font); return ASSET_LOAD_FAILED; } - /* Preload both variants so the menu can switch immediately. A failure of - * the optional 1x atlas must not hide the original 4x font. */ + /* 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)) { - AeronFontAtlas_Release(&slot->smooth_atlas); - memset(&slot->smooth_atlas, 0, sizeof slot->smooth_atlas); Aeron_LogWarn("xwa.remaster", "frontend font %d: optional smooth atlas unavailable", font_size); } Xwa2dFontAtlas_Free(&font); @@ -1681,7 +1678,7 @@ const AeronFontAtlas* XwaRemasterAssets_FrontendFont(XwaRemasterAssets* a, int f return &slot->smooth_atlas; } if (out_atlas_scale) - *out_atlas_scale = (float)XWA_ORIGINAL_FONT_SCALE; + *out_atlas_scale = 4.0f; return slot->atlas.loaded ? &slot->atlas : NULL; } } diff --git a/src/xwa_remaster/assets.h b/src/xwa_remaster/assets.h index 6da3bded..b46dc6a8 100644 --- a/src/xwa_remaster/assets.h +++ b/src/xwa_remaster/assets.h @@ -62,7 +62,7 @@ typedef struct XwaAssetRef { /* `root` is the mod folder. `prefer_original_2d` changes * source order; the alternate source is selected only when the first is absent. */ -XwaRemasterAssets* XwaRemasterAssets_Create(const char* root, int prefer_original_2d, int smooth_menu_fonts); +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); diff --git a/src/xwa_remaster/xwa_remaster.c b/src/xwa_remaster/xwa_remaster.c index 83f0c129..fb1b8122 100644 --- a/src/xwa_remaster/xwa_remaster.c +++ b/src/xwa_remaster/xwa_remaster.c @@ -407,7 +407,8 @@ 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, g.smooth_menu_fonts); + 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_video_options_screen.c b/src/xwa_runtime/config/modern_video_options_screen.c index 2c5b783a..3d5b399a 100644 --- a/src/xwa_runtime/config/modern_video_options_screen.c +++ b/src/xwa_runtime/config/modern_video_options_screen.c @@ -84,8 +84,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, &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 = From fbae64339e452db4b8eb6f4cc3429d66372b3d4e Mon Sep 17 00:00:00 2001 From: Rasi Talon Date: Sat, 7 Nov 2026 16:44:59 +0200 Subject: [PATCH 04/10] Tidy the head tracking change Share one poll path between the TrackIR and OpenTrack sources, with only the native NPClient part kept to Windows, and write the head-tracked camera rows through one function. Add the head offset to the packed view state directly rather than through pointers to its members. Keep the original reticle code in the non-modern branch, sort the new includes, center the head tracking menu like the video options, describe both Windows libraries in CMakeLists.txt, and format the new code. --- CMakeLists.txt | 4 +- src/xwa/flight/flight_view.c | 112 ++--- src/xwa/flight/hangar.c | 2 +- src/xwa/flight/hud/hud.c | 42 +- src/xwa/render/render_scene_core.c | 9 +- src/xwa_app/host_config.c | 21 +- src/xwa_remaster/flight.c | 13 +- src/xwa_remaster/hud_fixed.c | 7 +- .../config/modern_head_tracking_screen.c | 36 +- src/xwa_runtime/config/modern_input_options.c | 4 +- src/xwa_runtime/input/opentrack_udp.c | 232 ++++++----- src/xwa_runtime/input/trackir.c | 381 +++++++++--------- src/xwa_runtime/input/trackir.h | 12 +- src/xwa_runtime/runtime/flight_task.c | 2 +- src/xwa_runtime/snapshot/snapshot.c | 4 +- src/xwa_runtime/snapshot/snapshot_hud.c | 2 +- 16 files changed, 471 insertions(+), 412 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 90337e97..f2893840 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -319,8 +319,8 @@ target_link_libraries(xwa_input_options PUBLIC aeron) add_library(xwa_core ${XWA_RECOVERED_SOURCES} ${XWA_RUNTIME_SOURCES}) if(WIN32) - # NPClient is loaded at runtime from the existing TrackIR installation. - # Only the Windows registry API is linked; no TrackIR SDK is bundled. + # 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() diff --git a/src/xwa/flight/flight_view.c b/src/xwa/flight/flight_view.c index 380f9f7c..571a5dcf 100644 --- a/src/xwa/flight/flight_view.c +++ b/src/xwa/flight/flight_view.c @@ -14,12 +14,12 @@ #include "xwa/flight/yard.h" #include "xwa/util/time.h" #ifdef XWA_MODERN -#include "aeron/log.h" #include "aeron/asset/opt_model.h" -#include "xwa_runtime/input/trackir.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/trackir.h" #include "xwa_runtime/models/gunner_seats.h" #include "xwa_runtime/runtime/port.h" #include "xwa_runtime/snapshot/snapshot_camera.h" @@ -287,11 +287,30 @@ static void FVIEW_StoreCamShadow(const double m[9]) { * agreement: the classic culler and remastered scene must see the same view. */ static int FVIEW_TrackIRToQ15(double value) { long scaled = lround(value * 32768.0); - if (scaled < -32767) return -32767; - if (scaled > 32767) return 32767; + 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_StoreTrackIRCamera(const double rows[9], int forwardChanged) { + g_camMatR0_X = g_curMatR0_X = FVIEW_TrackIRToQ15(rows[0]); + g_camMatR0_Y = g_curMatR0_Y = FVIEW_TrackIRToQ15(rows[1]); + g_camMatR0_Z = g_curMatR0_Z = FVIEW_TrackIRToQ15(rows[2]); + g_camMatR1_X = g_curMatR1_X = FVIEW_TrackIRToQ15(rows[3]); + g_camMatR1_Y = g_curMatR1_Y = FVIEW_TrackIRToQ15(rows[4]); + g_camMatR1_Z = g_curMatR1_Z = FVIEW_TrackIRToQ15(rows[5]); + if (forwardChanged) { + g_camMatR2_X = g_curMatR2_X = FVIEW_TrackIRToQ15(rows[6]); + g_camMatR2_Y = g_curMatR2_Y = FVIEW_TrackIRToQ15(rows[7]); + g_camMatR2_Z = g_curMatR2_Z = FVIEW_TrackIRToQ15(rows[8]); + } + FVIEW_StoreCamShadow(rows); +} + static void FVIEW_ApplyTrackIRRoll(int16_t rollQ16) { float original[9]; double rows[9]; @@ -299,9 +318,11 @@ static void FVIEW_ApplyTrackIRRoll(int16_t rollQ16) { double co; double si; int i; - if (!rollQ16) return; + if (!rollQ16) + return; FVIEW_CopyRenderCameraRows(original); - for (i = 0; i < 9; ++i) rows[i] = (double)original[i]; + for (i = 0; i < 9; ++i) + rows[i] = (double)original[i]; angle = ((double)rollQ16 * 3.14159265358979323846) / 32768.0; co = cos(angle); si = sin(angle); @@ -309,13 +330,7 @@ static void FVIEW_ApplyTrackIRRoll(int16_t rollQ16) { rows[i] = co * (double)original[i] + si * (double)original[i + 3]; rows[i + 3] = -si * (double)original[i] + co * (double)original[i + 3]; } - g_camMatR0_X = g_curMatR0_X = FVIEW_TrackIRToQ15(rows[0]); - g_camMatR0_Y = g_curMatR0_Y = FVIEW_TrackIRToQ15(rows[1]); - g_camMatR0_Z = g_curMatR0_Z = FVIEW_TrackIRToQ15(rows[2]); - g_camMatR1_X = g_curMatR1_X = FVIEW_TrackIRToQ15(rows[3]); - g_camMatR1_Y = g_curMatR1_Y = FVIEW_TrackIRToQ15(rows[4]); - g_camMatR1_Z = g_curMatR1_Z = FVIEW_TrackIRToQ15(rows[5]); - FVIEW_StoreCamShadow(rows); + FVIEW_StoreTrackIRCamera(rows, 0); } /* Apply look to an already positioned camera. External cameras establish @@ -327,24 +342,19 @@ static void FVIEW_ApplyTrackIRLookToCamera(const XwaTrackIRPose* head) { double rows[9]; double up[3]; int i; - if (!head) return; + 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]; + 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); - g_camMatR0_X = g_curMatR0_X = FVIEW_TrackIRToQ15(rows[0]); - g_camMatR0_Y = g_curMatR0_Y = FVIEW_TrackIRToQ15(rows[1]); - g_camMatR0_Z = g_curMatR0_Z = FVIEW_TrackIRToQ15(rows[2]); - g_camMatR1_X = g_curMatR1_X = FVIEW_TrackIRToQ15(rows[3]); - g_camMatR1_Y = g_curMatR1_Y = FVIEW_TrackIRToQ15(rows[4]); - g_camMatR1_Z = g_curMatR1_Z = FVIEW_TrackIRToQ15(rows[5]); - g_camMatR2_X = g_curMatR2_X = FVIEW_TrackIRToQ15(rows[6]); - g_camMatR2_Y = g_curMatR2_Y = FVIEW_TrackIRToQ15(rows[7]); - g_camMatR2_Z = g_curMatR2_Z = FVIEW_TrackIRToQ15(rows[8]); - FVIEW_StoreCamShadow(rows); + FVIEW_StoreTrackIRCamera(rows, 1); FVIEW_ApplyTrackIRRoll(head->roll_q16); } @@ -353,21 +363,19 @@ static void FVIEW_ApplyTrackIRLookToCamera(const XwaTrackIRPose* head) { * to reconstruct fractional movement. The basis is the view BEFORE * head rotation, so leaning is independent of where the user looks. */ static void FVIEW_ApplyTrackIRTranslation(int playerIdx, const XwaTrackIRPose* head, - const float baseRows[9]) { + 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}; + const float local[3] = { -head->left_cm, head->up_cm, -head->back_cm }; float worldOffset[3]; - int* const cameraCoord[3] = { - &g_players[playerIdx].viewState.savedTargetX, - &g_players[playerIdx].viewState.savedTargetY, - &g_players[playerIdx].viewState.savedTargetZ - }; for (int axis = 0; axis < 3; ++axis) { - const float relative = local[0] * baseRows[axis] + - local[1] * baseRows[3 + axis] + local[2] * baseRows[6 + axis]; - worldOffset[axis] = relative * scale; - *cameraCoord[axis] += (int)lroundf(worldOffset[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]); XwaTrackIR_SetCameraOffset(worldOffset); } @@ -375,7 +383,8 @@ static void FVIEW_ApplyTrackIRTranslation(int playerIdx, const XwaTrackIRPose* h void FlightView_ApplyTrackIRToCurrentCamera(int playerIdx) { XwaTrackIRPose head; float baseRows[9]; - if (playerIdx != g_localPlayer || !XwaTrackIR_Poll(&head)) return; + if (playerIdx != g_localPlayer || !XwaTrackIR_Poll(&head)) + return; FVIEW_CopyRenderCameraRows(baseRows); FVIEW_ApplyTrackIRLookToCamera(&head); FVIEW_ApplyTrackIRTranslation(playerIdx, &head, baseRows); @@ -1010,7 +1019,8 @@ 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) XwaTrackIR_ClearPose(); + if (playerIdx == g_localPlayer) + XwaTrackIR_ClearPose(); #endif if (g_players[playerIdx].mapCameraState) { FlightMap_UpdateCamera(playerIdx); @@ -1246,8 +1256,8 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { } } else { #ifdef XWA_MODERN - XwaTrackIRPose trackir = {0}; - float seatRows[9] = {0}; + XwaTrackIRPose trackir = { 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, @@ -1272,12 +1282,11 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { 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_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 @@ -1302,7 +1311,8 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { #endif &g_objectTable[g_players[playerIdx].viewState.cameraFocusObjIdx], playerIdx); #ifdef XWA_MODERN - if (tracking) FVIEW_ApplyTrackIRRoll(trackir.roll_q16); + if (tracking) + FVIEW_ApplyTrackIRRoll(trackir.roll_q16); #endif g_players[playerIdx].viewState.savedTargetX = g_objectTable[g_players[playerIdx].viewState.cameraFocusObjIdx].world_x; @@ -1361,8 +1371,8 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { * - 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 (playerIdx == g_localPlayer && !g_players[playerIdx].mapCameraState && !g_filmRecording && + !g_filmPlaybackMode && (g_players[playerIdx].hyperspacePhase != PLAYER_HYPERSPACE_PHASE_NONE || (g_players[playerIdx].viewState.externalCameraActive && !g_players[playerIdx].viewState.playerInputBlocked))) { diff --git a/src/xwa/flight/hangar.c b/src/xwa/flight/hangar.c index db0ba658..e7935862 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/trackir.h" #include "xwa_runtime/models/articulation.h" #include "xwa_runtime/models/craft_definitions.h" #include "xwa_runtime/models/craft_sounds.h" @@ -56,7 +57,6 @@ #include "xwa_runtime/models/warhead_launchers.h" #include "xwa_runtime/models/warhead_pools.h" #include "xwa_runtime/models/weapon_stats.h" -#include "xwa_runtime/input/trackir.h" #include "xwa_runtime/snapshot/snapshot_hud.h" #include "xwa_runtime/timing/modern_flight_timing.h" #endif diff --git a/src/xwa/flight/hud/hud.c b/src/xwa/flight/hud/hud.c index 73edf316..e644b3ed 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/trackir.h" #include "xwa_runtime/models/component_strength.h" #include "xwa_runtime/models/mission_settings.h" #include "xwa_runtime/models/object_limits.h" @@ -43,7 +44,6 @@ #include "xwa_runtime/models/warhead_lock.h" #include "xwa_runtime/models/weapon_stats.h" #include "xwa_runtime/snapshot/snapshot_hud.h" -#include "xwa_runtime/input/trackir.h" #endif #include "xwa/render/effects.h" #include "xwa/render/renderer_internal.h" @@ -6932,17 +6932,16 @@ void Hud_DrawReticle3D(void) { uint8_t inRange; uint16_t lockRange; unsigned int lockRangeDivisor; - int16_t effectiveLookYaw = g_players[g_localPlayer].lookYawOffset; - int16_t effectiveLookPitch = g_players[g_localPlayer].lookPitchOffset; - int projectReticle = HUD_RETICLE_PROJECTED(); #ifdef XWA_MODERN + /* Head look moves the view like the cockpit look offsets do, so the + reticle follows the nose in the same way. */ XwaTrackIRPose head; const int trackirLook = XwaTrackIR_CurrentPose(&head) && (head.yaw_q16 || head.pitch_q16); - if (trackirLook) { - effectiveLookYaw = (int16_t)(effectiveLookYaw + head.yaw_q16); - effectiveLookPitch = (int16_t)(effectiveLookPitch + head.pitch_q16); - projectReticle = 1; - } + const int16_t lookYaw = + (int16_t)(g_players[g_localPlayer].lookYawOffset + (trackirLook ? head.yaw_q16 : 0)); + const int16_t lookPitch = + (int16_t)(g_players[g_localPlayer].lookPitchOffset + (trackirLook ? head.pitch_q16 : 0)); + const int projectReticle = trackirLook || HUD_RETICLE_PROJECTED(); #endif g_reticleDrawX = g_reticleCenterX; @@ -6986,13 +6985,22 @@ void Hud_DrawReticle3D(void) { #endif lockRangeDivisor = lockRange / 200; - if (Hud_AbsLookDegreesFromOffset(effectiveLookYaw) >= 45 || - Hud_AbsLookDegreesFromOffset(effectiveLookPitch) >= 45) { +#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; + #ifdef XWA_MODERN if (trackirLook && g_players[g_localPlayer].currentSeatIdx > 0) { /* The gun's existing Q15 firing vector is independent of head look. */ @@ -7003,7 +7011,7 @@ void Hud_DrawReticle3D(void) { #endif { pai_RotateLocalVectorToWorldScratch(&g_objectTable[g_players[g_localPlayer].objectIndex], 0, 0, - 1000000); + 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; @@ -7061,7 +7069,11 @@ void Hud_DrawReticle3D(void) { } } - if (projectReticle) { +#ifdef XWA_MODERN + if (projectReticle) { +#else + if (HUD_RETICLE_PROJECTED()) { +#endif int aimX; int drawX; @@ -7143,7 +7155,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/render/render_scene_core.c b/src/xwa/render/render_scene_core.c index 635fd8d1..44417fc0 100644 --- a/src/xwa/render/render_scene_core.c +++ b/src/xwa/render/render_scene_core.c @@ -6,10 +6,10 @@ #ifdef XWA_MODERN #include "xwa/frontend/frontend_display.h" /* g_flightRenderToFrontend */ +#include "xwa_runtime/input/trackir.h" #include "xwa_runtime/models/articulation.h" #include "xwa_runtime/models/gunner_seats.h" #include "xwa_runtime/models/opt_synth.h" -#include "xwa_runtime/input/trackir.h" #include #endif @@ -46,7 +46,8 @@ static int VirtualProtect(void* lpAddress, unsigned int dwSize, unsigned int flN * residual ensures fluid motion in classic F5 mode without filtering. */ static void RenderScene_ApplyTrackIRTranslation(Vec3f* pos, int cockpit_active) { float offset[3]; - if (!pos) return; + if (!pos) + return; XwaTrackIR_GetCameraOffset(offset); if (cockpit_active) { pos->x -= offset[0]; @@ -439,8 +440,8 @@ int16_t RenderScene_DrawObjectModel(ObjectRecord* obj) { g_players[g_localPlayer].viewState.cameraPanDeltaZ * g_cockpitPanPositionScale; } #ifdef XWA_MODERN - /* Adjust the eye relative to the cockpit without moving the craft. */ - RenderScene_ApplyTrackIRTranslation(&mesh.viewPos, g_cockpitViewActive); + /* Adjust the eye relative to the cockpit without moving the craft. */ + RenderScene_ApplyTrackIRTranslation(&mesh.viewPos, g_cockpitViewActive); #endif mesh.viewOrient[0] = g_viewMtx00; diff --git a/src/xwa_app/host_config.c b/src/xwa_app/host_config.c index 0b5545b4..adc8c594 100644 --- a/src/xwa_app/host_config.c +++ b/src/xwa_app/host_config.c @@ -966,7 +966,7 @@ static int host_config_controller_options(const AeronConfigFile* config, int req } static int host_config_head_tracking(const AeronConfigFile* config, int required, - XwaHeadTrackingOptions* head, char* error, size_t error_size) { + 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" }; @@ -976,11 +976,12 @@ static int host_config_head_tracking(const AeronConfigFile* config, int required int axis; if (!host_config_input_bool(config, "input.head_tracking.enabled", required, &head->enabled, error, - error_size)) { + error_size)) { return 0; } if (!source) { - if (!host_config_input_missing(required, "input.head_tracking.source", error, error_size)) return 0; + 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) { @@ -988,14 +989,14 @@ static int host_config_head_tracking(const AeronConfigFile* config, int required } 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'"); + 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; + if (!host_config_input_bool(config, key, required, &head->invert[axis], error, error_size)) + return 0; } return 1; } @@ -1495,13 +1496,15 @@ static int host_config_set_head_tracking(AeronConfigFile* document, const XwaHea char key[80]; int axis; if (!AeronConfigFile_SetBool(document, "input.head_tracking.enabled", head->enabled, error) || - !AeronConfigFile_SetString(document, "input.head_tracking.source", + !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; + if (!AeronConfigFile_SetBool(document, key, head->invert[axis], error)) + return 0; } return 1; } diff --git a/src/xwa_remaster/flight.c b/src/xwa_remaster/flight.c index 2dd313e8..42e41ddc 100644 --- a/src/xwa_remaster/flight.c +++ b/src/xwa_remaster/flight.c @@ -2379,8 +2379,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->trackir_head_offset[axis] - cockpit->camera_pan[axis] * 0.0625f - - sway[axis] - rotated_eye; + position[axis] = camera_local[axis] - cockpit->trackir_head_offset[axis] - + cockpit->camera_pan[axis] * 0.0625f - sway[axis] - rotated_eye; } } @@ -2414,8 +2414,8 @@ static int fl_cockpit_model_matrix(const XwaCockpit* cockpit, const float sway[3 /* 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->trackir_head_offset[axis] + - camera_rows[0 * 3 + axis] * delta[0] + - camera_rows[1 * 3 + axis] * delta[1] + camera_rows[2 * 3 + axis] * delta[2]; + 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); @@ -4537,9 +4537,8 @@ static void fl_submit_hyperspace_cockpit(AeronCommandBuffer* cmd, XwaRemasterAss /* 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.trackir_head_offset[j] + - s.crows[0 * 3 + j] * delta[0] + s.crows[1 * 3 + j] * delta[1] + - s.crows[2 * 3 + j] * delta[2]; + pw[j] = s.camera_local[j] - snap->cockpit.trackir_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 3191f3dd..efc589d0 100644 --- a/src/xwa_remaster/hud_fixed.c +++ b/src/xwa_remaster/hud_fixed.c @@ -888,10 +888,9 @@ static int fixed_project_reticle(const XwaSnapshot* snapshot, XwaHudProfileIndex 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]; + 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)) diff --git a/src/xwa_runtime/config/modern_head_tracking_screen.c b/src/xwa_runtime/config/modern_head_tracking_screen.c index f481fc7a..35eddb1b 100644 --- a/src/xwa_runtime/config/modern_head_tracking_screen.c +++ b/src/xwa_runtime/config/modern_head_tracking_screen.c @@ -8,16 +8,17 @@ #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 +#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" - }; + 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; @@ -28,11 +29,12 @@ int XwaModernHeadTrackingScreen_Update(int menu_center_x, int* cursor_row) { int axis; int back; - if (!cursor_row) return 0; + if (!cursor_row) + return 0; XwaModernInputOptions_Get(&options); enabled = (uint8_t)(options.head_tracking.enabled != 0); previous_enabled = enabled; - #ifdef _WIN32 +#ifdef _WIN32 source = (uint8_t)options.head_tracking.source; #else /* Native NPClient is Windows-only; the one visible choice is UDP. */ @@ -41,22 +43,22 @@ int XwaModernHeadTrackingScreen_Update(int menu_center_x, int* cursor_row) { for (axis = 0; axis < XWA_HEAD_TRACK_AXIS_COUNT; ++axis) { invert[axis] = (uint8_t)(options.head_tracking.invert[axis] != 0); } - /* One master switch, one source, six inversion controls, and Back. */ - XwaModernOptionsMenu_Begin(&menu, menu_center_x, 120, cursor_row, 9); + 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); + 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); + 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 +#ifdef _WIN32 options.head_tracking.source = source; #else options.head_tracking.source = XWA_HEAD_TRACK_SOURCE_OPENTRACK_UDP; diff --git a/src/xwa_runtime/config/modern_input_options.c b/src/xwa_runtime/config/modern_input_options.c index f6ddc27a..77e5adbe 100644 --- a/src/xwa_runtime/config/modern_input_options.c +++ b/src/xwa_runtime/config/modern_input_options.c @@ -218,8 +218,8 @@ static int XwaModernInputOptions_AreEqual(const XwaModernInputOptions* lhs, lhs->mouse_sensitivity == rhs->mouse_sensitivity && lhs->mouse_invert_y == rhs->mouse_invert_y && 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 && + memcmp(lhs->head_tracking.invert, rhs->head_tracking.invert, sizeof(lhs->head_tracking.invert)) == + 0 && XwaControllerOptions_AreEqual(&lhs->controller, &rhs->controller); } diff --git a/src/xwa_runtime/input/opentrack_udp.c b/src/xwa_runtime/input/opentrack_udp.c index 0a7808be..6e417d60 100644 --- a/src/xwa_runtime/input/opentrack_udp.c +++ b/src/xwa_runtime/input/opentrack_udp.c @@ -24,8 +24,12 @@ 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 }; +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; @@ -37,142 +41,150 @@ static int s_winsock_started; #endif void XwaOpenTrackUdp_Shutdown(void) { - if (s_socket != OT_INVALID_SOCKET) { + if (s_socket != OT_INVALID_SOCKET) { #ifdef _WIN32 - closesocket(s_socket); + closesocket(s_socket); #else - close(s_socket); + close(s_socket); #endif - s_socket = OT_INVALID_SOCKET; - } + s_socket = OT_INVALID_SOCKET; + } #ifdef _WIN32 - if (s_winsock_started) { - WSACleanup(); - s_winsock_started = 0; - } + 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); + 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; + 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; + u_long nonblocking = 1; + WSADATA wsa_data; + if (WSAStartup(MAKEWORD(2, 2), &wsa_data) != 0) + return 0; + s_winsock_started = 1; #else - int flags; + int flags; #endif - s_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); - if (s_socket == OT_INVALID_SOCKET) goto failed; + 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; + 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; + 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; + 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; + 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; + 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); + 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, XwaTrackIRPose* 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; +static int decode_packet(const unsigned char* bytes, int size, const XwaHeadTrackingOptions* options, + XwaTrackIRPose* 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(XwaTrackIRPose* pose, const XwaHeadTrackingOptions* options) { - uint32_t now = XwaTime_GetElapsedTicks(); - unsigned char packet[49]; /* One extra byte detects oversized datagrams. */ - XwaTrackIRPose 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; + uint32_t now = XwaTime_GetElapsedTicks(); + unsigned char packet[49]; /* One extra byte detects oversized datagrams. */ + XwaTrackIRPose 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/trackir.c b/src/xwa_runtime/input/trackir.c index 63781d9b..7ef4047c 100644 --- a/src/xwa_runtime/input/trackir.c +++ b/src/xwa_runtime/input/trackir.c @@ -1,8 +1,9 @@ -/* TrackIR runtime bridge. Uses the NPClient64.dll installed by TrackIR. - * No SDK headers, code, or DLLs are distributed with OpenXWA. */ +/* Head tracking for the local player's camera: native TrackIR through the + * NPClient64.dll that TrackIR installs (Windows only), or OpenTrack's UDP + * output. No NaturalPoint SDK headers, code, or DLLs are distributed. */ #include "xwa_runtime/input/trackir.h" -#include "xwa_runtime/input/opentrack_udp.h" #include "xwa_runtime/config/modern_input_options.h" +#include "xwa_runtime/input/opentrack_udp.h" #include #include @@ -13,51 +14,53 @@ static int s_pose_valid; static float s_camera_offset[3]; void XwaTrackIR_ClearPose(void) { - memset(&s_pose, 0, sizeof s_pose); - memset(s_camera_offset, 0, sizeof s_camera_offset); - s_pose_valid = 0; + memset(&s_pose, 0, sizeof s_pose); + memset(s_camera_offset, 0, sizeof s_camera_offset); + s_pose_valid = 0; } void XwaTrackIR_SetCameraOffset(const float world_offset[3]) { - if (world_offset && s_pose_valid) { - memcpy(s_camera_offset, world_offset, sizeof s_camera_offset); - } + if (world_offset && s_pose_valid) { + memcpy(s_camera_offset, world_offset, sizeof s_camera_offset); + } } void XwaTrackIR_GetCameraOffset(float world_offset[3]) { - if (world_offset) { - memcpy(world_offset, s_camera_offset, sizeof s_camera_offset); - } + if (world_offset) { + memcpy(world_offset, s_camera_offset, sizeof s_camera_offset); + } } int XwaTrackIR_CurrentPose(XwaTrackIRPose* out) { - if (out) *out = s_pose; - return s_pose_valid; + if (out) { + *out = s_pose; + } + return s_pose_valid; } #ifdef _WIN32 #define WIN32_LEAN_AND_MEAN -#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]; + 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*); +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; @@ -70,172 +73,186 @@ 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); + 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) { - XwaOpenTrackUdp_Shutdown(); - if (s_module) { - if (s_stop) s_stop(); - if (s_unregister) s_unregister(); - FreeLibrary(s_module); - } - XwaTrackIR_ClearPose(); - 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 void trackir_native_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) { - XwaTrackIR_Shutdown(); - return 0; - } - - /* 1000 is NaturalPoint's public SDK/developer test profile. */ - if (reg_window(hwnd) != 0) { - XwaTrackIR_Shutdown(); - return 0; - } - s_window = hwnd; - if (reg_profile(1000) != 0 || request(1 | 2 | 4 | 16 | 32 | 64) != 0 || - start() != 0) { - XwaTrackIR_Shutdown(); - return 0; - } - return 1; -} +static int trackir_native_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; -int XwaTrackIR_Poll(XwaTrackIRPose* out) { - TrackIRRawFrame frame; - XwaModernInputOptions options; - ULONGLONG now = GetTickCount64(); - if (!out) return 0; - memset(out, 0, sizeof *out); - XwaTrackIR_ClearPose(); - XwaModernInputOptions_Get(&options); - if (options.head_tracking.enabled && - options.head_tracking.source == XWA_HEAD_TRACK_SOURCE_OPENTRACK_UDP) { - if (s_module) XwaTrackIR_Shutdown(); /* Release native NPClient on source change. */ - if (XwaOpenTrackUdp_Poll(out, &options.head_tracking)) { - s_pose = *out; - s_pose_valid = 1; - return 1; - } - return 0; - } - XwaOpenTrackUdp_Shutdown(); - if (!options.head_tracking.enabled || - options.head_tracking.source != XWA_HEAD_TRACK_SOURCE_TRACKIR) { - /* Disabled means no DLL connection, polling, or residual pose. */ - if (s_module) XwaTrackIR_Shutdown(); - return 0; - } - - if (!s_module) { - if (now < s_next_attempt) return 0; - s_next_attempt = now + 3000; - if (!TrackIR_Connect()) { - /* Failed registration may call Shutdown(), which clears the - * retry 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.head_tracking.invert[XWA_HEAD_TRACK_AXIS_YAW]); - out->pitch_q16 = angle_q16(frame.pitch, !options.head_tracking.invert[XWA_HEAD_TRACK_AXIS_PITCH]); - out->roll_q16 = angle_q16(frame.roll, !options.head_tracking.invert[XWA_HEAD_TRACK_AXIS_ROLL]); - out->left_cm = frame.x * (50.0f / 16383.0f) * - (options.head_tracking.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 * (50.0f / 16383.0f) * - (options.head_tracking.invert[XWA_HEAD_TRACK_AXIS_Y] ? -1.0f : 1.0f); - out->back_cm = frame.z * (50.0f / 16383.0f) * - (options.head_tracking.invert[XWA_HEAD_TRACK_AXIS_Z] ? -1.0f : 1.0f); - s_pose = *out; - s_pose_valid = 1; - return 1; + 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) { + trackir_native_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) { + trackir_native_shutdown(); + return 0; + } + return 1; } +static int trackir_native_poll(XwaTrackIRPose* 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_native_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 +static void trackir_native_shutdown(void) {} -int XwaTrackIR_Poll(XwaTrackIRPose* out) { - XwaModernInputOptions options; - if (!out) return 0; - memset(out, 0, sizeof *out); - XwaTrackIR_ClearPose(); - XwaModernInputOptions_Get(&options); - if (options.head_tracking.enabled && - options.head_tracking.source == XWA_HEAD_TRACK_SOURCE_OPENTRACK_UDP) { - if (XwaOpenTrackUdp_Poll(out, &options.head_tracking)) { - s_pose = *out; - s_pose_valid = 1; - return 1; - } - return 0; - } - XwaOpenTrackUdp_Shutdown(); - return 0; +static int trackir_native_poll(XwaTrackIRPose* out, const XwaHeadTrackingOptions* options) { + (void)out; + (void)options; + return 0; } +#endif void XwaTrackIR_Shutdown(void) { - XwaOpenTrackUdp_Shutdown(); - XwaTrackIR_ClearPose(); + XwaOpenTrackUdp_Shutdown(); + trackir_native_shutdown(); + XwaTrackIR_ClearPose(); } -#endif +int XwaTrackIR_Poll(XwaTrackIRPose* out) { + XwaModernInputOptions options; + int polled; + + if (!out) { + return 0; + } + memset(out, 0, sizeof *out); + XwaTrackIR_ClearPose(); + XwaModernInputOptions_Get(&options); + if (!options.head_tracking.enabled) { + /* Disabled means no connection, polling, or leftover pose. */ + XwaOpenTrackUdp_Shutdown(); + trackir_native_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) { + trackir_native_shutdown(); + polled = XwaOpenTrackUdp_Poll(out, &options.head_tracking); + } else { + XwaOpenTrackUdp_Shutdown(); + polled = trackir_native_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/trackir.h b/src/xwa_runtime/input/trackir.h index ee376de6..8f51ce6a 100644 --- a/src/xwa_runtime/input/trackir.h +++ b/src/xwa_runtime/input/trackir.h @@ -5,12 +5,12 @@ /* Optional, Windows-only head pose. No NaturalPoint SDK files are included. */ typedef struct XwaTrackIRPose { - 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; + 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; } XwaTrackIRPose; /* Returns zero if unavailable, unfocused, paused, or data is stale. */ diff --git a/src/xwa_runtime/runtime/flight_task.c b/src/xwa_runtime/runtime/flight_task.c index ce1902de..1e9de0f2 100644 --- a/src/xwa_runtime/runtime/flight_task.c +++ b/src/xwa_runtime/runtime/flight_task.c @@ -1,6 +1,6 @@ -#include "xwa_runtime/input/trackir.h" #include "xwa_runtime/runtime/flight_task.h" #include "xwa/flight/hangar.h" +#include "xwa_runtime/input/trackir.h" #include "xwa_runtime/runtime/film_benchmark.h" #include "xwa_runtime/runtime/flight_pause_task.h" #include "xwa_runtime/runtime/port.h" diff --git a/src/xwa_runtime/snapshot/snapshot.c b/src/xwa_runtime/snapshot/snapshot.c index 085726b7..495614f8 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/trackir.h" #include "xwa_runtime/models/articulation.h" #include "xwa_runtime/models/cockpit_alert_lights.h" #include "xwa_runtime/models/cockpit_view.h" @@ -25,7 +26,6 @@ #include "xwa_runtime/models/skins.h" #include "xwa_runtime/models/surfaces.h" #include "xwa_runtime/snapshot/snapshot_camera.h" -#include "xwa_runtime/input/trackir.h" #include "xwa_runtime/snapshot/snapshot_flight_map.h" #include "xwa_runtime/snapshot/snapshot_hud.h" #include "xwa_runtime/timing/host_clock.h" @@ -1888,7 +1888,7 @@ void XwaSnapshot_CaptureFlight(void) { c->world_pos[0] = vs->savedTargetX; c->world_pos[1] = vs->savedTargetY; c->world_pos[2] = vs->savedTargetZ; - float trackir_world_offset[3] = {0}; + float trackir_world_offset[3] = { 0 }; int32_t untracked_pos[3]; XwaTrackIR_GetCameraOffset(trackir_world_offset); for (int axis = 0; axis < 3; ++axis) { diff --git a/src/xwa_runtime/snapshot/snapshot_hud.c b/src/xwa_runtime/snapshot/snapshot_hud.c index a4fa099f..ff85f0b5 100644 --- a/src/xwa_runtime/snapshot/snapshot_hud.c +++ b/src/xwa_runtime/snapshot/snapshot_hud.c @@ -1,4 +1,3 @@ -#include "xwa_runtime/input/trackir.h" #include "xwa_runtime/snapshot/snapshot_hud.h" #include "xwa/assets/flight_model.h" @@ -13,6 +12,7 @@ #include "xwa/render/renderer.h" #include "xwa_runtime/camera/exterior_hud.h" #include "xwa_runtime/input/mouse_flight.h" +#include "xwa_runtime/input/trackir.h" #include "xwa_runtime/models/craft_hud.h" #include "xwa_runtime/models/craft_weapons.h" #include "xwa_runtime/models/weapon_stats.h" From 808e066be0e980dcaa2e08fd989f76b6598f0382 Mon Sep 17 00:00:00 2001 From: Rasi Talon Date: Sat, 7 Nov 2026 17:04:33 +0200 Subject: [PATCH 05/10] Name the head tracking layer for both sources The pose, the per-frame state and the source switch serve TrackIR and OpenTrack alike, so they move to head_tracking.c as XwaHeadTracking_* and XwaHeadPose. trackir.c keeps only the native NPClient source, next to opentrack_udp.c. The TrackIR name stays where it means the device: the source option, its config value and the NPClient code. --- CMakeLists.txt | 6 +- src/xwa/flight/flight_view.c | 67 +++++++------ src/xwa/flight/hangar.c | 6 +- src/xwa/flight/hud/hud.c | 17 ++-- src/xwa/render/render_scene_core.c | 14 +-- src/xwa/render/renderer.h | 2 +- src/xwa_remaster/flight.c | 9 +- src/xwa_remaster/hud_fixed.c | 4 +- .../config/modern_head_tracking_screen.c | 4 +- src/xwa_runtime/config/modern_input_options.h | 2 +- src/xwa_runtime/input/head_tracking.c | 76 +++++++++++++++ src/xwa_runtime/input/head_tracking.h | 29 ++++++ src/xwa_runtime/input/opentrack_udp.c | 8 +- src/xwa_runtime/input/opentrack_udp.h | 4 +- src/xwa_runtime/input/trackir.c | 96 +++---------------- src/xwa_runtime/input/trackir.h | 26 ++--- src/xwa_runtime/runtime/flight_task.c | 4 +- src/xwa_runtime/snapshot/snapshot.c | 14 +-- src/xwa_runtime/snapshot/snapshot.h | 4 +- src/xwa_runtime/snapshot/snapshot_hud.c | 10 +- 20 files changed, 210 insertions(+), 192 deletions(-) create mode 100644 src/xwa_runtime/input/head_tracking.c create mode 100644 src/xwa_runtime/input/head_tracking.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f2893840..ec408fcd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -282,8 +282,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/trackir.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 @@ -871,8 +872,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/trackir.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/src/xwa/flight/flight_view.c b/src/xwa/flight/flight_view.c index 571a5dcf..52958759 100644 --- a/src/xwa/flight/flight_view.c +++ b/src/xwa/flight/flight_view.c @@ -19,7 +19,7 @@ #include "xwa/flight/flight_debug.h" #include "xwa_runtime/camera/camera_motion.h" #include "xwa_runtime/hooks/orientation_hook.h" -#include "xwa_runtime/input/trackir.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" @@ -282,10 +282,10 @@ static void FVIEW_StoreCamShadow(const double m[9]) { g_camShadowValid = 1; FVIEW_CopyShadowToViewMtx(); } -/* TrackIR rolls the observer about the VIEW direction, not the ship. +/* 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_TrackIRToQ15(double value) { +static int FVIEW_HeadTrackingToQ15(double value) { long scaled = lround(value * 32768.0); if (scaled < -32767) return -32767; @@ -296,22 +296,22 @@ static int FVIEW_TrackIRToQ15(double value) { /* Writes the head-tracked rows to the Q15 camera and its double shadow. Roll leaves the forward row as it is. */ -static void FVIEW_StoreTrackIRCamera(const double rows[9], int forwardChanged) { - g_camMatR0_X = g_curMatR0_X = FVIEW_TrackIRToQ15(rows[0]); - g_camMatR0_Y = g_curMatR0_Y = FVIEW_TrackIRToQ15(rows[1]); - g_camMatR0_Z = g_curMatR0_Z = FVIEW_TrackIRToQ15(rows[2]); - g_camMatR1_X = g_curMatR1_X = FVIEW_TrackIRToQ15(rows[3]); - g_camMatR1_Y = g_curMatR1_Y = FVIEW_TrackIRToQ15(rows[4]); - g_camMatR1_Z = g_curMatR1_Z = FVIEW_TrackIRToQ15(rows[5]); +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_TrackIRToQ15(rows[6]); - g_camMatR2_Y = g_curMatR2_Y = FVIEW_TrackIRToQ15(rows[7]); - g_camMatR2_Z = g_curMatR2_Z = FVIEW_TrackIRToQ15(rows[8]); + 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_ApplyTrackIRRoll(int16_t rollQ16) { +static void FVIEW_ApplyHeadRoll(int16_t rollQ16) { float original[9]; double rows[9]; double angle; @@ -330,14 +330,14 @@ static void FVIEW_ApplyTrackIRRoll(int16_t rollQ16) { rows[i] = co * (double)original[i] + si * (double)original[i + 3]; rows[i + 3] = -si * (double)original[i] + co * (double)original[i + 3]; } - FVIEW_StoreTrackIRCamera(rows, 0); + 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_ApplyTrackIRLookToCamera(const XwaTrackIRPose* head) { +static void FVIEW_ApplyHeadLookToCamera(const XwaHeadPose* head) { float source[9]; double rows[9]; double up[3]; @@ -354,16 +354,15 @@ static void FVIEW_ApplyTrackIRLookToCamera(const XwaTrackIRPose* head) { * 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_StoreTrackIRCamera(rows, 1); - FVIEW_ApplyTrackIRRoll(head->roll_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_ApplyTrackIRTranslation(int playerIdx, const XwaTrackIRPose* head, - const float baseRows[9]) { +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]; @@ -376,18 +375,18 @@ static void FVIEW_ApplyTrackIRTranslation(int playerIdx, const XwaTrackIRPose* h 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]); - XwaTrackIR_SetCameraOffset(worldOffset); + XwaHeadTracking_SetCameraOffset(worldOffset); } /* Overlay local head movement on a camera that already established its base pose. */ -void FlightView_ApplyTrackIRToCurrentCamera(int playerIdx) { - XwaTrackIRPose head; +void FlightView_ApplyHeadTrackingToCurrentCamera(int playerIdx) { + XwaHeadPose head; float baseRows[9]; - if (playerIdx != g_localPlayer || !XwaTrackIR_Poll(&head)) + if (playerIdx != g_localPlayer || !XwaHeadTracking_Poll(&head)) return; FVIEW_CopyRenderCameraRows(baseRows); - FVIEW_ApplyTrackIRLookToCamera(&head); - FVIEW_ApplyTrackIRTranslation(playerIdx, &head, baseRows); + FVIEW_ApplyHeadLookToCamera(&head); + FVIEW_ApplyHeadTranslation(playerIdx, &head, baseRows); } #endif @@ -1020,7 +1019,7 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { XwaCameraMotion_Update(playerIdx); /* Per-frame local head pose only. Other players, replay and maps remain unchanged. */ if (playerIdx == g_localPlayer) - XwaTrackIR_ClearPose(); + XwaHeadTracking_ClearPose(); #endif if (g_players[playerIdx].mapCameraState) { FlightMap_UpdateCamera(playerIdx); @@ -1256,7 +1255,7 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { } } else { #ifdef XWA_MODERN - XwaTrackIRPose trackir = { 0 }; + XwaHeadPose head = { 0 }; float seatRows[9] = { 0 }; int tracking = 0; /* Track the local user's normal cockpit view (pilot OR gun turret). @@ -1268,7 +1267,7 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { ((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 = XwaTrackIR_Poll(&trackir); + tracking = XwaHeadTracking_Poll(&head); } #endif g_players[playerIdx].viewState.viewRoll = g_objectTable[cameraFocusObjIdx].roll; @@ -1303,8 +1302,8 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { 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 ? trackir.pitch_q16 : 0)), - (int16_t)(g_players[playerIdx].viewState.hudAimY + (tracking ? trackir.yaw_q16 : 0)), + (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, @@ -1312,7 +1311,7 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { &g_objectTable[g_players[playerIdx].viewState.cameraFocusObjIdx], playerIdx); #ifdef XWA_MODERN if (tracking) - FVIEW_ApplyTrackIRRoll(trackir.roll_q16); + FVIEW_ApplyHeadRoll(head.roll_q16); #endif g_players[playerIdx].viewState.savedTargetX = g_objectTable[g_players[playerIdx].viewState.cameraFocusObjIdx].world_x; @@ -1334,7 +1333,7 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { g_players[playerIdx].viewState.cameraPanDeltaZ >> 4; #ifdef XWA_MODERN if (tracking) { - FVIEW_ApplyTrackIRTranslation(playerIdx, &trackir, seatRows); + FVIEW_ApplyHeadTranslation(playerIdx, &head, seatRows); } #endif } @@ -1376,7 +1375,7 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { (g_players[playerIdx].hyperspacePhase != PLAYER_HYPERSPACE_PHASE_NONE || (g_players[playerIdx].viewState.externalCameraActive && !g_players[playerIdx].viewState.playerInputBlocked))) { - FlightView_ApplyTrackIRToCurrentCamera(playerIdx); + FlightView_ApplyHeadTrackingToCurrentCamera(playerIdx); } #endif } diff --git a/src/xwa/flight/hangar.c b/src/xwa/flight/hangar.c index e7935862..857d78f2 100644 --- a/src/xwa/flight/hangar.c +++ b/src/xwa/flight/hangar.c @@ -45,7 +45,7 @@ #include "xwa/math/scalar.h" #include "xwa/math/trig2.h" #ifdef XWA_MODERN -#include "xwa_runtime/input/trackir.h" +#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" @@ -2853,7 +2853,7 @@ void Hangar_RenderReadyScreen(void) { if (g_hangarAutoCam) { #ifdef XWA_MODERN - XwaTrackIR_ClearPose(); + XwaHeadTracking_ClearPose(); if (!g_filmPlaybackMode) { player->viewState.savedTargetX = s_hangarCameraBase[0]; player->viewState.savedTargetY = s_hangarCameraBase[1]; @@ -2867,7 +2867,7 @@ void Hangar_RenderReadyScreen(void) { s_hangarCameraBase[1] = player->viewState.savedTargetY; s_hangarCameraBase[2] = player->viewState.savedTargetZ; if (!g_filmRecording) { - FlightView_ApplyTrackIRToCurrentCamera(g_localPlayer); + FlightView_ApplyHeadTrackingToCurrentCamera(g_localPlayer); } } #endif diff --git a/src/xwa/flight/hud/hud.c b/src/xwa/flight/hud/hud.c index e644b3ed..77e46156 100644 --- a/src/xwa/flight/hud/hud.c +++ b/src/xwa/flight/hud/hud.c @@ -36,7 +36,7 @@ #include "xwa/math/trig2.h" #ifdef XWA_MODERN #include "xwa_runtime/camera/exterior_hud.h" -#include "xwa_runtime/input/trackir.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" @@ -3449,7 +3449,7 @@ void Hud_SetupReticle(void) { deltaY = (float)(screenY - centerY); #ifdef XWA_MODERN /* Weapon indicators retain their ship-relative layout during head tracking. */ - if (XwaTrackIR_CurrentPose(NULL)) { + if (XwaHeadTracking_CurrentPose(NULL)) { const ModelWeaponHardpoint* point = &g_modelDefs[craft->modelDefIndex].weaponHardpoints[reticleSlot]; if (point->x || point->z) { @@ -6935,13 +6935,12 @@ void Hud_DrawReticle3D(void) { #ifdef XWA_MODERN /* Head look moves the view like the cockpit look offsets do, so the reticle follows the nose in the same way. */ - XwaTrackIRPose head; - const int trackirLook = XwaTrackIR_CurrentPose(&head) && (head.yaw_q16 || head.pitch_q16); - const int16_t lookYaw = - (int16_t)(g_players[g_localPlayer].lookYawOffset + (trackirLook ? head.yaw_q16 : 0)); + 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 + (trackirLook ? head.pitch_q16 : 0)); - const int projectReticle = trackirLook || HUD_RETICLE_PROJECTED(); + (int16_t)(g_players[g_localPlayer].lookPitchOffset + (headLook ? head.pitch_q16 : 0)); + const int projectReticle = headLook || HUD_RETICLE_PROJECTED(); #endif g_reticleDrawX = g_reticleCenterX; @@ -7002,7 +7001,7 @@ void Hud_DrawReticle3D(void) { int worldZ; #ifdef XWA_MODERN - if (trackirLook && g_players[g_localPlayer].currentSeatIdx > 0) { + 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]); diff --git a/src/xwa/render/render_scene_core.c b/src/xwa/render/render_scene_core.c index 44417fc0..4fa9475f 100644 --- a/src/xwa/render/render_scene_core.c +++ b/src/xwa/render/render_scene_core.c @@ -6,7 +6,7 @@ #ifdef XWA_MODERN #include "xwa/frontend/frontend_display.h" /* g_flightRenderToFrontend */ -#include "xwa_runtime/input/trackir.h" +#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" @@ -41,14 +41,14 @@ static int VirtualProtect(void* lpAddress, unsigned int dwSize, unsigned int flN #ifdef XWA_MODERN /* Classic model paths override the cockpit model's world-relative position - * with its untracked seat hardpoint, losing TrackIR eye displacement. World + * 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_ApplyTrackIRTranslation(Vec3f* pos, int cockpit_active) { +static void RenderScene_ApplyHeadTranslation(Vec3f* pos, int cockpit_active) { float offset[3]; if (!pos) return; - XwaTrackIR_GetCameraOffset(offset); + XwaHeadTracking_GetCameraOffset(offset); if (cockpit_active) { pos->x -= offset[0]; pos->y -= offset[1]; @@ -441,7 +441,7 @@ int16_t RenderScene_DrawObjectModel(ObjectRecord* obj) { } #ifdef XWA_MODERN /* Adjust the eye relative to the cockpit without moving the craft. */ - RenderScene_ApplyTrackIRTranslation(&mesh.viewPos, g_cockpitViewActive); + RenderScene_ApplyHeadTranslation(&mesh.viewPos, g_cockpitViewActive); #endif mesh.viewOrient[0] = g_viewMtx00; @@ -837,7 +837,7 @@ int RenderScene_DrawObjectModelHardware(ObjectRecord* obj) { } #ifdef XWA_MODERN /* Adjust the eye relative to the cockpit without moving the craft. */ - RenderScene_ApplyTrackIRTranslation(&mesh.viewPos, g_cockpitViewActive); + RenderScene_ApplyHeadTranslation(&mesh.viewPos, g_cockpitViewActive); #endif viewMatrix.m[0] = g_viewMtx00; @@ -1088,7 +1088,7 @@ void RenderScene_DrawNoAssetSourceModel(ObjectRecord* obj, int nodeSwitchIndex) } #ifdef XWA_MODERN /* Adjust the eye relative to the cockpit without moving the craft. */ - RenderScene_ApplyTrackIRTranslation(&mesh.viewPos, g_cockpitViewActive); + RenderScene_ApplyHeadTranslation(&mesh.viewPos, g_cockpitViewActive); #endif mesh.viewOrient[0] = g_viewMtx00; diff --git a/src/xwa/render/renderer.h b/src/xwa/render/renderer.h index b440de1b..a799abac 100644 --- a/src/xwa/render/renderer.h +++ b/src/xwa/render/renderer.h @@ -1092,7 +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_ApplyTrackIRToCurrentCamera(int playerIdx); +void FlightView_ApplyHeadTrackingToCurrentCamera(int playerIdx); void FVIEW_SaveRenderCameraStateForViewport(void); void FVIEW_RestoreRenderCameraStateForViewport(void); #endif diff --git a/src/xwa_remaster/flight.c b/src/xwa_remaster/flight.c index 42e41ddc..88fe9c54 100644 --- a/src/xwa_remaster/flight.c +++ b/src/xwa_remaster/flight.c @@ -2363,8 +2363,7 @@ static int fl_temporal_pose_changed(const XwaSnapshot* current, const XwaSnapsho 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->trackir_head_offset, prev_cockpit->trackir_head_offset, - sizeof cockpit->trackir_head_offset) != 0; + memcmp(cockpit->head_offset, prev_cockpit->head_offset, sizeof cockpit->head_offset) != 0; } /* Rotate inverted gunner cockpits about the model-local X axis, @@ -2379,7 +2378,7 @@ 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->trackir_head_offset[axis] - + position[axis] = camera_local[axis] - cockpit->head_offset[axis] - cockpit->camera_pan[axis] * 0.0625f - sway[axis] - rotated_eye; } } @@ -2413,7 +2412,7 @@ static int fl_cockpit_model_matrix(const XwaCockpit* cockpit, const float sway[3 for (int axis = 0; axis < 3; ++axis) { /* 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->trackir_head_offset[axis] + + 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]; } @@ -4537,7 +4536,7 @@ static void fl_submit_hyperspace_cockpit(AeronCommandBuffer* cmd, XwaRemasterAss /* 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.trackir_head_offset[j] + s.crows[0 * 3 + j] * delta[0] + + 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) { diff --git a/src/xwa_remaster/hud_fixed.c b/src/xwa_remaster/hud_fixed.c index efc589d0..ff76ebb9 100644 --- a/src/xwa_remaster/hud_fixed.c +++ b/src/xwa_remaster/hud_fixed.c @@ -882,8 +882,8 @@ static int fixed_project_reticle(const XwaSnapshot* snapshot, XwaHudProfileIndex if (!XwaRemasterFlight_ProjectView(flight_view, center_eye, &target_x, &target_y)) return 0; if (h->reticle.seat > 0 && h->reticle.turret_aim_valid) { - /* Project the gun's actual pre-TrackIR aim direction through the - * head-tracked camera. Projecting ship-forward here was incorrect + /* 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]; diff --git a/src/xwa_runtime/config/modern_head_tracking_screen.c b/src/xwa_runtime/config/modern_head_tracking_screen.c index 35eddb1b..a3674a03 100644 --- a/src/xwa_runtime/config/modern_head_tracking_screen.c +++ b/src/xwa_runtime/config/modern_head_tracking_screen.c @@ -3,7 +3,7 @@ #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/trackir.h" +#include "xwa_runtime/input/head_tracking.h" #include @@ -69,7 +69,7 @@ int XwaModernHeadTrackingScreen_Update(int menu_center_x, int* cursor_row) { if (XwaModernInputOptions_Set(&options) && previous_enabled && !enabled) { /* Disconnect immediately, including when settings are changed * outside a mission and no subsequent camera poll is expected. */ - XwaTrackIR_Shutdown(); + XwaHeadTracking_Shutdown(); } } diff --git a/src/xwa_runtime/config/modern_input_options.h b/src/xwa_runtime/config/modern_input_options.h index b113ea05..488019a6 100644 --- a/src/xwa_runtime/config/modern_input_options.h +++ b/src/xwa_runtime/config/modern_input_options.h @@ -81,7 +81,7 @@ enum { typedef struct XwaHeadTrackingOptions { int enabled; - /* Enumeration deliberately kept separate from the runtime TrackIR ABI, + /* 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]; 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/opentrack_udp.c b/src/xwa_runtime/input/opentrack_udp.c index 6e417d60..d8ddb611 100644 --- a/src/xwa_runtime/input/opentrack_udp.c +++ b/src/xwa_runtime/input/opentrack_udp.c @@ -35,7 +35,7 @@ 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 XwaTrackIRPose s_last_pose; +static XwaHeadPose s_last_pose; #ifdef _WIN32 static int s_winsock_started; #endif @@ -119,7 +119,7 @@ static int16_t degrees_to_q16(double angle, int sign) { } static int decode_packet(const unsigned char* bytes, int size, const XwaHeadTrackingOptions* options, - XwaTrackIRPose* pose) { + XwaHeadPose* pose) { double values[6]; if (!bytes || size != 48 || !options || !pose || sizeof(double) != 8) return 0; @@ -150,10 +150,10 @@ static int decode_packet(const unsigned char* bytes, int size, const XwaHeadTrac return 1; } -int XwaOpenTrackUdp_Poll(XwaTrackIRPose* pose, const XwaHeadTrackingOptions* options) { +int XwaOpenTrackUdp_Poll(XwaHeadPose* pose, const XwaHeadTrackingOptions* options) { uint32_t now = XwaTime_GetElapsedTicks(); unsigned char packet[49]; /* One extra byte detects oversized datagrams. */ - XwaTrackIRPose latest; + XwaHeadPose latest; if (!pose || !options) return 0; diff --git a/src/xwa_runtime/input/opentrack_udp.h b/src/xwa_runtime/input/opentrack_udp.h index 88835d10..a9b278db 100644 --- a/src/xwa_runtime/input/opentrack_udp.h +++ b/src/xwa_runtime/input/opentrack_udp.h @@ -2,12 +2,12 @@ #define XWA_RUNTIME_OPENTRACK_UDP_H #include "xwa_runtime/config/modern_input_options.h" -#include "xwa_runtime/input/trackir.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(XwaTrackIRPose* pose, const XwaHeadTrackingOptions* options); +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 index 7ef4047c..0f1cd2c3 100644 --- a/src/xwa_runtime/input/trackir.c +++ b/src/xwa_runtime/input/trackir.c @@ -1,45 +1,11 @@ -/* Head tracking for the local player's camera: native TrackIR through the - * NPClient64.dll that TrackIR installs (Windows only), or OpenTrack's UDP - * output. No NaturalPoint SDK headers, code, or DLLs are distributed. */ +/* 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" -#include "xwa_runtime/config/modern_input_options.h" -#include "xwa_runtime/input/opentrack_udp.h" - -#include -#include - -/* Stored separately from recovered simulation/player state. */ -static XwaTrackIRPose s_pose; -static int s_pose_valid; -static float s_camera_offset[3]; - -void XwaTrackIR_ClearPose(void) { - memset(&s_pose, 0, sizeof s_pose); - memset(s_camera_offset, 0, sizeof s_camera_offset); - s_pose_valid = 0; -} - -void XwaTrackIR_SetCameraOffset(const float world_offset[3]) { - if (world_offset && s_pose_valid) { - memcpy(s_camera_offset, world_offset, sizeof s_camera_offset); - } -} - -void XwaTrackIR_GetCameraOffset(float world_offset[3]) { - if (world_offset) { - memcpy(world_offset, s_camera_offset, sizeof s_camera_offset); - } -} - -int XwaTrackIR_CurrentPose(XwaTrackIRPose* out) { - if (out) { - *out = s_pose; - } - return s_pose_valid; -} #ifdef _WIN32 #define WIN32_LEAN_AND_MEAN +#include +#include #include #include @@ -83,7 +49,7 @@ static int16_t angle_q16(float raw, int invert) { return (int16_t)lrintf(value); } -static void trackir_native_shutdown(void) { +void XwaTrackIR_Shutdown(void) { if (s_module) { if (s_stop) { s_stop(); @@ -103,7 +69,7 @@ static void trackir_native_shutdown(void) { s_next_attempt = 0; } -static int trackir_native_connect(void) { +static int trackir_connect(void) { wchar_t dll_path[MAX_PATH]; DWORD bytes = sizeof(dll_path); DWORD pid = 0; @@ -153,19 +119,19 @@ static int trackir_native_connect(void) { 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) { - trackir_native_shutdown(); + 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) { - trackir_native_shutdown(); + XwaTrackIR_Shutdown(); return 0; } return 1; } -static int trackir_native_poll(XwaTrackIRPose* out, const XwaHeadTrackingOptions* options) { +int XwaTrackIR_Poll(XwaHeadPose* out, const XwaHeadTrackingOptions* options) { const ULONGLONG now = GetTickCount64(); const float cm_scale = 50.0f / 16383.0f; TrackIRRawFrame frame; @@ -174,7 +140,7 @@ static int trackir_native_poll(XwaTrackIRPose* out, const XwaHeadTrackingOptions if (now < s_next_attempt) { return 0; } - if (!trackir_native_connect()) { + if (!trackir_connect()) { /* A failed connect clears the deadline; never try loading the DLL every render frame. */ s_next_attempt = now + 3000; @@ -210,49 +176,11 @@ static int trackir_native_poll(XwaTrackIRPose* out, const XwaHeadTrackingOptions return 1; } #else -static void trackir_native_shutdown(void) {} +void XwaTrackIR_Shutdown(void) {} -static int trackir_native_poll(XwaTrackIRPose* out, const XwaHeadTrackingOptions* options) { +int XwaTrackIR_Poll(XwaHeadPose* out, const XwaHeadTrackingOptions* options) { (void)out; (void)options; return 0; } #endif - -void XwaTrackIR_Shutdown(void) { - XwaOpenTrackUdp_Shutdown(); - trackir_native_shutdown(); - XwaTrackIR_ClearPose(); -} - -int XwaTrackIR_Poll(XwaTrackIRPose* out) { - XwaModernInputOptions options; - int polled; - - if (!out) { - return 0; - } - memset(out, 0, sizeof *out); - XwaTrackIR_ClearPose(); - XwaModernInputOptions_Get(&options); - if (!options.head_tracking.enabled) { - /* Disabled means no connection, polling, or leftover pose. */ - XwaOpenTrackUdp_Shutdown(); - trackir_native_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) { - trackir_native_shutdown(); - polled = XwaOpenTrackUdp_Poll(out, &options.head_tracking); - } else { - XwaOpenTrackUdp_Shutdown(); - polled = trackir_native_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/trackir.h b/src/xwa_runtime/input/trackir.h index 8f51ce6a..540d46a1 100644 --- a/src/xwa_runtime/input/trackir.h +++ b/src/xwa_runtime/input/trackir.h @@ -1,27 +1,13 @@ #ifndef XWA_RUNTIME_TRACKIR_H #define XWA_RUNTIME_TRACKIR_H -#include +#include "xwa_runtime/config/modern_input_options.h" +#include "xwa_runtime/input/head_tracking.h" -/* Optional, Windows-only head pose. No NaturalPoint SDK files are included. */ -typedef struct XwaTrackIRPose { - 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; -} XwaTrackIRPose; - -/* Returns zero if unavailable, unfocused, paused, or data is stale. */ -int XwaTrackIR_Poll(XwaTrackIRPose* out); -/* Last pose sampled for the current flight frame; zero when tracking is unavailable. */ -int XwaTrackIR_CurrentPose(XwaTrackIRPose* out); -void XwaTrackIR_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 XwaTrackIR_SetCameraOffset(const float world_offset[3]); -void XwaTrackIR_GetCameraOffset(float world_offset[3]); +/* 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/runtime/flight_task.c b/src/xwa_runtime/runtime/flight_task.c index 1e9de0f2..fdee0da6 100644 --- a/src/xwa_runtime/runtime/flight_task.c +++ b/src/xwa_runtime/runtime/flight_task.c @@ -1,6 +1,6 @@ #include "xwa_runtime/runtime/flight_task.h" #include "xwa/flight/hangar.h" -#include "xwa_runtime/input/trackir.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" @@ -1952,7 +1952,7 @@ void XwaFlightTask_Tick(void) { } int XwaFlightTask_Shutdown(void) { - XwaTrackIR_Shutdown(); + 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 495614f8..5987b35d 100644 --- a/src/xwa_runtime/snapshot/snapshot.c +++ b/src/xwa_runtime/snapshot/snapshot.c @@ -15,7 +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/trackir.h" +#include "xwa_runtime/input/head_tracking.h" #include "xwa_runtime/models/articulation.h" #include "xwa_runtime/models/cockpit_alert_lights.h" #include "xwa_runtime/models/cockpit_view.h" @@ -1888,16 +1888,16 @@ void XwaSnapshot_CaptureFlight(void) { c->world_pos[0] = vs->savedTargetX; c->world_pos[1] = vs->savedTargetY; c->world_pos[2] = vs->savedTargetZ; - float trackir_world_offset[3] = { 0 }; + float head_world_offset[3] = { 0 }; int32_t untracked_pos[3]; - XwaTrackIR_GetCameraOffset(trackir_world_offset); + XwaHeadTracking_GetCameraOffset(head_world_offset); for (int axis = 0; axis < 3; ++axis) { - /* Lookup the existing camera fraction using its pre-TrackIR position. */ - untracked_pos[axis] = c->world_pos[axis] - (int32_t)lroundf(trackir_world_offset[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] += trackir_world_offset[axis] - roundf(trackir_world_offset[axis]); + c->world_pos_fraction[axis] += head_world_offset[axis] - roundf(head_world_offset[axis]); } FVIEW_CopyRenderCameraRows(c->rows); c->view_pitch = vs->viewPitch; @@ -2030,7 +2030,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; - XwaTrackIR_GetCameraOffset(k->trackir_head_offset); + 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 8c507744..a441b6ce 100644 --- a/src/xwa_runtime/snapshot/snapshot.h +++ b/src/xwa_runtime/snapshot/snapshot.h @@ -690,7 +690,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 trackir_head_offset[3]; /* transient observer translation, OPT units */ + 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 classic anchors the weapon-fire pulse point @@ -1081,7 +1081,7 @@ typedef struct XwaHudReticle { int16_t look_yaw, look_pitch; uint8_t seat; uint8_t turret_auto_fire; - /* Transient direction of the turret boresight, before TrackIR rotates + /* 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]; diff --git a/src/xwa_runtime/snapshot/snapshot_hud.c b/src/xwa_runtime/snapshot/snapshot_hud.c index ff85f0b5..a7027e51 100644 --- a/src/xwa_runtime/snapshot/snapshot_hud.c +++ b/src/xwa_runtime/snapshot/snapshot_hud.c @@ -11,8 +11,8 @@ #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/input/trackir.h" #include "xwa_runtime/models/craft_hud.h" #include "xwa_runtime/models/craft_weapons.h" #include "xwa_runtime/models/weapon_stats.h" @@ -505,14 +505,14 @@ 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; - XwaTrackIRPose head; - const int trackirLook = XwaTrackIR_CurrentPose(&head) && (head.yaw_q16 || head.pitch_q16); - if (trackirLook) { + 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)(trackirLook && player->currentSeatIdx > 0); + 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); From 21d9c9b9afb5318d5bb7f8ea4182ce27e5a5803e Mon Sep 17 00:00:00 2001 From: raistlen007 Date: Sat, 10 Oct 2026 19:52:31 +0300 Subject: [PATCH 06/10] feat(input): support two controllers and expanded 64-button mappings Port only dual-controller configuration, UI, merged axes, button actions and event handling from input-gameplay. Preserve the tentative TrackIR baseline and cross-platform OpenTrack UDP support. --- src/xwa/config/game_config.c | 12 +- src/xwa/flight/flight.c | 8 + src/xwa_app/host_config.c | 241 +++++++++------- src/xwa_app/main.c | 1 + .../config/modern_controller_options_screen.c | 201 +++++++------ .../config/modern_controller_options_screen.h | 1 + src/xwa_runtime/config/modern_input_options.c | 117 ++++---- src/xwa_runtime/config/modern_input_options.h | 8 +- .../config/modern_input_options_screen.c | 7 +- src/xwa_runtime/input/controller_mapping.c | 263 +++++++++++++----- src/xwa_runtime/input/controller_mapping.h | 8 +- .../input/winmm_joystick_provider.c | 8 +- 12 files changed, 563 insertions(+), 312 deletions(-) diff --git a/src/xwa/config/game_config.c b/src/xwa/config/game_config.c index 4cdb94ff..8503eb8d 100644 --- a/src/xwa/config/game_config.c +++ b/src/xwa/config/game_config.c @@ -953,6 +953,12 @@ 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; @@ -5376,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..1122c91a 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,11 @@ 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); +#endif } if (key == 0) { diff --git a/src/xwa_app/host_config.c b/src/xwa_app/host_config.c index 7563e673..cabeb68a 100644 --- a/src/xwa_app/host_config.c +++ b/src/xwa_app/host_config.c @@ -837,6 +837,7 @@ 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" }; @@ -844,32 +845,32 @@ static int host_config_controller_axes(const AeronConfigFile* config, int requir 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 +880,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 +901,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,45 +939,48 @@ 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)) { - 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); + 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, @@ -1020,6 +1022,7 @@ static int host_config_head_tracking(const AeronConfigFile* config, int required 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) || @@ -1028,11 +1031,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)) { @@ -1076,6 +1107,22 @@ static int host_config_validate_input_maps(const AeronConfigFile* config, char* "input.controller.joystick.axes.throttle", "input.controller.joystick.axes.roll", "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.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.buttons", }; size_t i; @@ -1363,7 +1410,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]; @@ -1377,7 +1424,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; @@ -1385,21 +1432,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); @@ -1447,7 +1494,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]; @@ -1461,11 +1508,11 @@ 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", @@ -1474,7 +1521,7 @@ static int host_config_set_controller_profile(AeronConfigFile* document, const c 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; } @@ -1483,18 +1530,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; @@ -1503,7 +1550,7 @@ 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_head_tracking(AeronConfigFile* document, const XwaHeadTrackingOptions* head, @@ -1527,32 +1574,38 @@ static int host_config_set_head_tracking(AeronConfigFile* document, const XwaHea 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) { + AeronConfigError* error) { static const char* const mode_names[] = { "position", "rate" }; - - if (!XwaModernInputOptions_Validate(options)) { - return 0; - } + 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) && - host_config_set_head_tracking(document, &options->head_tracking, 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, diff --git a/src/xwa_app/main.c b/src/xwa_app/main.c index 7decd1eb..ac23ef61 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); } } diff --git a/src/xwa_runtime/config/modern_controller_options_screen.c b/src/xwa_runtime/config/modern_controller_options_screen.c index fd61c217..6346138b 100644 --- a/src/xwa_runtime/config/modern_controller_options_screen.c +++ b/src/xwa_runtime/config/modern_controller_options_screen.c @@ -50,6 +50,25 @@ typedef struct ControllerBindingEditState { static ControllerCaptureState g_controllerCapture = { -1, -1, -1, 0, { 0 } }; static ControllerBindingEditState g_controllerBindingEdit; 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; +} + +/* 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 +125,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) { @@ -429,46 +434,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 +484,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); @@ -507,7 +512,8 @@ int XwaModernControllerAxesScreen_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); XwaModernOptionsMenu_Begin(&menu, menu_center_x, 60, cursor_row, 13); if (g_controllerCapture.axis >= 0 && menu.key == XWA_MODERN_MENU_KEY_ESCAPE) { @@ -515,7 +521,7 @@ int XwaModernControllerAxesScreen_Update(int menu_center_x, int* cursor_row) { XwaModernOptionsMenu_TakeEscape(&menu); } ControllerScreen_UpdateAxisCapture(&options, selected); - XwaModernOptionsMenu_DrawTitle(&menu, "Controller Axis Mapping"); + XwaModernOptionsMenu_DrawTitle(&menu, g_controllerSlot ? "Controller 2 Axis Mapping" : "Controller 1 Axis Mapping"); for (axis = 0; axis < XWA_CONTROLLER_LOGICAL_AXIS_COUNT; ++axis) { char source_name[48]; const int16_t live = ControllerScreen_Axis(selected, profile->axes[axis].source); @@ -597,9 +603,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 +888,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 +930,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 +972,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 +1015,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"); 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_input_options.c b/src/xwa_runtime/config/modern_input_options.c index 77e5adbe..b0fea9ae 100644 --- a/src/xwa_runtime/config/modern_input_options.c +++ b/src/xwa_runtime/config/modern_input_options.c @@ -102,86 +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->head_tracking.source != XWA_HEAD_TRACK_SOURCE_TRACKIR && - options->head_tracking.source != XWA_HEAD_TRACK_SOURCE_OPENTRACK_UDP) || - 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->head_tracking.enabled = options->head_tracking.enabled != 0; -#ifndef _WIN32 - /* The shipped defaults name native TrackIR for Windows. On other hosts, - * normalize the effective selection to the only available UDP provider, - * even when the user enables tracking without opening the settings menu. */ - 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; - } - 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; @@ -204,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 && @@ -220,7 +223,8 @@ static int XwaModernInputOptions_AreEqual(const XwaModernInputOptions* lhs, 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->controller, &rhs->controller) && + XwaControllerOptions_AreEqual(&lhs->controller2, &rhs->controller2); } void XwaModernInputOptions_Configure(const XwaModernInputOptions* defaults, @@ -273,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 488019a6..4745ec7c 100644 --- a/src/xwa_runtime/config/modern_input_options.h +++ b/src/xwa_runtime/config/modern_input_options.h @@ -16,8 +16,8 @@ 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_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, @@ -59,6 +59,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; @@ -96,6 +98,7 @@ typedef struct XwaModernInputOptions { int mouse_sensitivity; int mouse_invert_y; XwaControllerOptions controller; + XwaControllerOptions controller2; XwaHeadTrackingOptions head_tracking; } XwaModernInputOptions; @@ -111,6 +114,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 32eb4ab3..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, 145, cursor_row, 7); + 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,12 @@ 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; } diff --git a/src/xwa_runtime/input/controller_mapping.c b/src/xwa_runtime/input/controller_mapping.c index e9f5acd1..d77e79a9 100644 --- a/src/xwa_runtime/input/controller_mapping.c +++ b/src/xwa_runtime/input/controller_mapping.c @@ -12,8 +12,14 @@ 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]; } XwaControllerMappingState; static XwaControllerMappingState g_controllerMapping; @@ -40,20 +46,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 +185,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 +196,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 +221,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 +244,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 +263,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 +281,33 @@ 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; +} + +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; } uint32_t XwaControllerMapping_SelectedInstanceId(void) { @@ -291,61 +320,143 @@ 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; + 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(); - - 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 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; } -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..6c30f0aa 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,7 @@ 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]); +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/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)); From f0652e34eea0a702e565baa11d4e9c32b815a8a7 Mon Sep 17 00:00:00 2001 From: raistlen007 Date: Sat, 10 Oct 2026 19:54:01 +0300 Subject: [PATCH 07/10] feat(input): add gated Hyperdrive lever and robust controller binding pages Integrate the lever's armed crossing and navigation gate, paged axis UI, and regression test. Preserve joystick held-switch baseline handling and OpenTrack compatibility. Exclude temporary CI and packaging changes. --- resources/remaster/config.yaml | 2 + src/xwa/flight/flight.c | 21 +++++ src/xwa_app/host_config.c | 14 +++- .../config/modern_controller_options_screen.c | 78 +++++++++++-------- src/xwa_runtime/config/modern_input_options.h | 3 +- src/xwa_runtime/input/controller_mapping.c | 56 +++++++++++++ src/xwa_runtime/input/controller_mapping.h | 2 + src/xwa_runtime/input/hyperdrive_axis.h | 22 ++++++ tests/hyperdrive_axis_regression.c | 27 +++++++ 9 files changed, 189 insertions(+), 36 deletions(-) create mode 100644 src/xwa_runtime/input/hyperdrive_axis.h create mode 100644 tests/hyperdrive_axis_regression.c diff --git a/resources/remaster/config.yaml b/resources/remaster/config.yaml index 5f1a4b00..1a16d812 100644 --- a/resources/remaster/config.yaml +++ b/resources/remaster/config.yaml @@ -34,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' @@ -80,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/flight/flight.c b/src/xwa/flight/flight.c index 1122c91a..c1015c11 100644 --- a/src/xwa/flight/flight.c +++ b/src/xwa/flight/flight.c @@ -2650,6 +2650,27 @@ uint16_t FlightInput_Read(int playerIdxOrSentinel) { /* 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 } diff --git a/src/xwa_app/host_config.c b/src/xwa_app/host_config.c index cabeb68a..2c0d9b21 100644 --- a/src/xwa_app/host_config.c +++ b/src/xwa_app/host_config.c @@ -839,8 +839,9 @@ 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; @@ -1099,6 +1100,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", @@ -1106,6 +1108,7 @@ 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", @@ -1115,6 +1118,7 @@ static int host_config_validate_input_maps(const AeronConfigFile* config, char* "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", @@ -1122,6 +1126,7 @@ static int host_config_validate_input_maps(const AeronConfigFile* config, char* "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; @@ -1515,8 +1520,9 @@ static int host_config_set_controller_actions(AeronConfigFile* document, const c 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; diff --git a/src/xwa_runtime/config/modern_controller_options_screen.c b/src/xwa_runtime/config/modern_controller_options_screen.c index 6346138b..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,6 +51,7 @@ 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) { @@ -58,6 +61,7 @@ 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. */ @@ -314,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; @@ -365,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) { @@ -378,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; } } @@ -413,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) { @@ -497,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; @@ -511,24 +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_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, g_controllerSlot ? "Controller 2 Axis Mapping" : "Controller 1 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); } @@ -556,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; } } @@ -570,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; @@ -1030,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_input_options.h b/src/xwa_runtime/config/modern_input_options.h index 4745ec7c..ee50cd82 100644 --- a/src/xwa_runtime/config/modern_input_options.h +++ b/src/xwa_runtime/config/modern_input_options.h @@ -15,7 +15,7 @@ extern "C" { enum { XWA_MODERN_MOUSE_SENSITIVITY_MIN = 1, XWA_MODERN_MOUSE_SENSITIVITY_MAX = 9, - XWA_CONTROLLER_LOGICAL_AXIS_COUNT = 4, + 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, @@ -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 { diff --git a/src/xwa_runtime/input/controller_mapping.c b/src/xwa_runtime/input/controller_mapping.c index d77e79a9..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" @@ -20,6 +21,8 @@ typedef struct XwaControllerMappingState { 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; @@ -293,6 +296,8 @@ void XwaControllerMapping_SetOptions(const XwaControllerOptions* options) { 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) { @@ -308,6 +313,8 @@ void XwaControllerMapping_SetSecondaryOptions(const XwaControllerOptions* option 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) { @@ -347,6 +354,8 @@ int XwaControllerMapping_ConsumeSelectionChange(void) { 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; @@ -389,6 +398,53 @@ int XwaControllerMapping_GetState(XwaControllerLogicalState* state) { return found; } +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[20]) { const AeronControllerSnapshot* controller = XwaControllerMapping_SelectedController(); const XwaControllerOptions* opts = XwaControllerMapping_ControllerForSlot(0) diff --git a/src/xwa_runtime/input/controller_mapping.h b/src/xwa_runtime/input/controller_mapping.h index 6c30f0aa..a6eb5922 100644 --- a/src/xwa_runtime/input/controller_mapping.h +++ b/src/xwa_runtime/input/controller_mapping.h @@ -31,6 +31,8 @@ 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); +/* 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. */ 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/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; +} From a1b2d981c16026b8d8bf56191f07cf9ab3b76eeb Mon Sep 17 00:00:00 2001 From: raistlen007 Date: Sat, 10 Oct 2026 19:56:47 +0300 Subject: [PATCH 08/10] chore(config): isolate private dual-controller user YAML settings Load, create and save config.dual-controller.yaml exclusively for this branch. Keep shipped remaster/config.yaml as the required defaults and never read or modify regular user config.yaml. --- src/xwa_app/host_config.c | 24 ++++++++++++------------ src/xwa_app/host_config.h | 2 ++ src/xwa_app/main.c | 4 ++-- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/xwa_app/host_config.c b/src/xwa_app/host_config.c index 2c0d9b21..12c73750 100644 --- a/src/xwa_app/host_config.c +++ b/src/xwa_app/host_config.c @@ -1083,7 +1083,7 @@ 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; } @@ -1141,7 +1141,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); @@ -1224,7 +1224,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; @@ -1271,14 +1271,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; @@ -1357,7 +1357,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; @@ -1375,7 +1375,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; @@ -1395,7 +1395,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; @@ -1621,7 +1621,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; @@ -1641,7 +1641,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; @@ -1659,7 +1659,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 ac23ef61..6df8d8e3 100644 --- a/src/xwa_app/main.c +++ b/src/xwa_app/main.c @@ -394,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(); From 2d919d5dfafa8535b02215b7b9f345c1d4d67e3d Mon Sep 17 00:00:00 2001 From: Rasi Talon Date: Wed, 11 Nov 2026 06:58:53 +0200 Subject: [PATCH 09/10] fix(input): don't crash when quitting with an in-flight menu open Flight shutdown releases DirectInput before it detaches the frontend modal surfaces, and the detach drains the keyboard through the freed device. --- src/xwa/input/dinput.c | 5 +++++ 1 file changed, 5 insertions(+) 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; From 07478af840c82b87d57ede62430b5f83327a4950 Mon Sep 17 00:00:00 2001 From: Rasi Talon Date: Wed, 11 Nov 2026 07:01:23 +0200 Subject: [PATCH 10/10] fix(head-tracking): keep head tracking during hyperspace The HD renderer now draws the jump with the cockpit camera that the game keeps when hyperspace starts. Apply head look and lean to that cockpit camera during hyperspace too, keep the lean's fractional part, and only turn the classic hyperspace view so the lean is not added twice. --- src/xwa/flight/flight_view.c | 21 ++++++++++++++------- src/xwa_runtime/snapshot/snapshot.c | 9 ++++++++- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/xwa/flight/flight_view.c b/src/xwa/flight/flight_view.c index ca4155e6..682de1f5 100644 --- a/src/xwa/flight/flight_view.c +++ b/src/xwa/flight/flight_view.c @@ -1265,9 +1265,10 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { 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. */ + * 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].hyperspacePhase == PLAYER_HYPERSPACE_PHASE_NONE && g_players[playerIdx].cockpitVisible && ((g_players[playerIdx].currentSeatIdx == 0 && g_players[playerIdx].cockpitLookAvailable) || (g_players[playerIdx].currentSeatIdx > 0 && g_players[playerIdx].cockpitToggleAvailable)) && @@ -1379,11 +1380,17 @@ void FlightView_UpdatePlayerCamera(int playerIdx) { * 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 && - (g_players[playerIdx].hyperspacePhase != PLAYER_HYPERSPACE_PHASE_NONE || - (g_players[playerIdx].viewState.externalCameraActive && - !g_players[playerIdx].viewState.playerInputBlocked))) { - FlightView_ApplyHeadTrackingToCurrentCamera(playerIdx); + !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 } diff --git a/src/xwa_runtime/snapshot/snapshot.c b/src/xwa_runtime/snapshot/snapshot.c index 27ec6fff..65edb245 100644 --- a/src/xwa_runtime/snapshot/snapshot.c +++ b/src/xwa_runtime/snapshot/snapshot.c @@ -1955,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 =