/** * @file options.cpp * * Load and save options from the diablo.ini file. */ #include #include #include #include #include #include #include #include #include "control.h" #include "controls/controller.h" #include "controls/game_controls.h" #include "controls/plrctrls.h" #include "discord/discord.h" #include "engine/demomode.h" #include "engine/sound_defs.hpp" #include "hwcursor.hpp" #include "options.h" #include "platform/locale.hpp" #include "qol/monhealthbar.h" #include "qol/xpbar.h" #include "utils/algorithm/container.hpp" #include "utils/display.h" #include "utils/file_util.h" #include "utils/ini.hpp" #include "utils/language.h" #include "utils/log.hpp" #include "utils/logged_fstream.hpp" #include "utils/paths.h" #include "utils/str_cat.hpp" #include "utils/str_split.hpp" #include "utils/utf8.hpp" namespace devilution { #ifndef DEFAULT_WIDTH #define DEFAULT_WIDTH 640 #endif #ifndef DEFAULT_HEIGHT #define DEFAULT_HEIGHT 480 #endif #ifndef DEFAULT_AUDIO_SAMPLE_RATE #define DEFAULT_AUDIO_SAMPLE_RATE 22050 #endif #ifndef DEFAULT_AUDIO_CHANNELS #define DEFAULT_AUDIO_CHANNELS 2 #endif #ifndef DEFAULT_AUDIO_BUFFER_SIZE #define DEFAULT_AUDIO_BUFFER_SIZE 2048 #endif #ifndef DEFAULT_AUDIO_RESAMPLING_QUALITY #define DEFAULT_AUDIO_RESAMPLING_QUALITY 3 #endif namespace { std::optional ini; #if defined(__ANDROID__) || (defined(TARGET_OS_IPHONE) && TARGET_OS_IPHONE == 1) constexpr OptionEntryFlags OnlyIfSupportsWindowed = OptionEntryFlags::Invisible; #else constexpr OptionEntryFlags OnlyIfSupportsWindowed = OptionEntryFlags::None; #endif constexpr size_t NumResamplers = #ifdef DEVILUTIONX_RESAMPLER_SPEEX 1 + #endif #ifdef DVL_AULIB_SUPPORTS_SDL_RESAMPLER 1 + #endif 0; std::string GetIniPath() { auto path = paths::ConfigPath() + std::string("diablo.ini"); return path; } void LoadIni() { std::vector buffer; auto path = GetIniPath(); FILE *file = OpenFile(path.c_str(), "rb"); if (file != nullptr) { uintmax_t size; if (GetFileSize(path.c_str(), &size)) { buffer.resize(size); if (std::fread(buffer.data(), size, 1, file) != 1) { const char *errorMessage = std::strerror(errno); if (errorMessage == nullptr) errorMessage = ""; LogError(LogCategory::System, "std::fread: failed with \"{}\"", errorMessage); buffer.clear(); } } std::fclose(file); } tl::expected result = Ini::parse(std::string_view(buffer.data(), buffer.size())); if (!result.has_value()) app_fatal(result.error()); ini.emplace(std::move(result).value()); } void SaveIni() { if (!ini.has_value()) return; if (!ini->changed()) return; RecursivelyCreateDir(paths::ConfigPath().c_str()); const std::string iniPath = GetIniPath(); LoggedFStream out; if (!out.Open(iniPath.c_str(), "wb")) { LogError("Failed to open ini file for writing at {}: {}", iniPath, std::strerror(errno)); return; } const std::string newContents = ini->serialize(); if (out.Write(newContents.data(), newContents.size())) { ini->markAsUnchanged(); } out.Close(); } #if SDL_VERSION_ATLEAST(2, 0, 0) bool HardwareCursorDefault() { #if defined(__ANDROID__) || (defined(TARGET_OS_IPHONE) && TARGET_OS_IPHONE == 1) // See https://github.com/diasurgical/devilutionX/issues/2502 return false; #else return HardwareCursorSupported(); #endif } #endif void OptionGrabInputChanged() { #ifdef USE_SDL1 SDL_WM_GrabInput(*sgOptions.Gameplay.grabInput ? SDL_GRAB_ON : SDL_GRAB_OFF); #else if (ghMainWnd != nullptr) SDL_SetWindowGrab(ghMainWnd, *sgOptions.Gameplay.grabInput ? SDL_TRUE : SDL_FALSE); #endif } void OptionExperienceBarChanged() { if (!gbRunGame) return; if (*sgOptions.Gameplay.experienceBar) InitXPBar(); else FreeXPBar(); } void OptionEnemyHealthBarChanged() { if (!gbRunGame) return; if (*sgOptions.Gameplay.enemyHealthBar) InitMonsterHealthBar(); else FreeMonsterHealthBar(); } #if !defined(USE_SDL1) || defined(__3DS__) void ResizeWindowAndUpdateResolutionOptions() { ResizeWindow(); #ifndef __3DS__ sgOptions.Graphics.resolution.InvalidateList(); #endif } #endif void OptionShowFPSChanged() { if (*sgOptions.Graphics.showFPS) EnableFrameCount(); else frameflag = false; } void OptionLanguageCodeChanged() { UnloadFonts(); LanguageInitialize(); LoadLanguageArchive(); } void OptionGameModeChanged() { gbIsHellfire = *sgOptions.GameMode.gameMode == StartUpGameMode::Hellfire; discord_manager::UpdateMenu(true); } void OptionSharewareChanged() { gbIsSpawn = *sgOptions.GameMode.shareware; } void OptionAudioChanged() { effects_cleanup_sfx(); music_stop(); snd_deinit(); snd_init(); music_start(TMUSIC_INTRO); if (gbRunGame) sound_init(); else ui_sound_init(); } } // namespace /** Game options */ Options sgOptions; #if SDL_VERSION_ATLEAST(2, 0, 0) bool HardwareCursorSupported() { #if (defined(TARGET_OS_IPHONE) && TARGET_OS_IPHONE == 1) return false; #else SDL_version v; SDL_GetVersion(&v); return SDL_VERSIONNUM(v.major, v.minor, v.patch) >= SDL_VERSIONNUM(2, 0, 12); #endif } #endif void LoadOptions() { LoadIni(); for (OptionCategoryBase *pCategory : sgOptions.GetCategories()) { for (OptionEntryBase *pEntry : pCategory->GetEntries()) { pEntry->LoadFromIni(pCategory->GetKey()); } } ini->getUtf8Buf("Hellfire", "SItem", sgOptions.Hellfire.szItem, sizeof(sgOptions.Hellfire.szItem)); ini->getUtf8Buf("Network", "Bind Address", "0.0.0.0", sgOptions.Network.szBindAddress, sizeof(sgOptions.Network.szBindAddress)); ini->getUtf8Buf("Network", "Previous Game ID", sgOptions.Network.szPreviousZTGame, sizeof(sgOptions.Network.szPreviousZTGame)); ini->getUtf8Buf("Network", "Previous Host", sgOptions.Network.szPreviousHost, sizeof(sgOptions.Network.szPreviousHost)); for (size_t i = 0; i < QUICK_MESSAGE_OPTIONS; i++) { std::span values = ini->get("NetMsg", QuickMessages[i].key); std::vector &result = sgOptions.Chat.szHotKeyMsgs[i]; result.clear(); result.reserve(values.size()); for (const Ini::Value &value : values) { result.emplace_back(value.value); } } ini->getUtf8Buf("Controller", "Mapping", sgOptions.Controller.szMapping, sizeof(sgOptions.Controller.szMapping)); sgOptions.Controller.fDeadzone = ini->getFloat("Controller", "deadzone", 0.07F); #ifdef __vita__ sgOptions.Controller.bRearTouch = ini->getBool("Controller", "Enable Rear Touchpad", true); #endif if (demo::IsRunning()) demo::OverrideOptions(); } void SaveOptions() { if (demo::IsRunning()) return; for (OptionCategoryBase *pCategory : sgOptions.GetCategories()) { for (OptionEntryBase *pEntry : pCategory->GetEntries()) { pEntry->SaveToIni(pCategory->GetKey()); } } ini->set("Hellfire", "SItem", sgOptions.Hellfire.szItem); ini->set("Network", "Bind Address", sgOptions.Network.szBindAddress); ini->set("Network", "Previous Game ID", sgOptions.Network.szPreviousZTGame); ini->set("Network", "Previous Host", sgOptions.Network.szPreviousHost); for (size_t i = 0; i < QUICK_MESSAGE_OPTIONS; i++) { ini->set("NetMsg", QuickMessages[i].key, sgOptions.Chat.szHotKeyMsgs[i]); } ini->set("Controller", "Mapping", sgOptions.Controller.szMapping); ini->set("Controller", "deadzone", sgOptions.Controller.fDeadzone); #ifdef __vita__ ini->set("Controller", "Enable Rear Touchpad", sgOptions.Controller.bRearTouch); #endif SaveIni(); } std::string_view OptionEntryBase::GetName() const { return _(name); } std::string_view OptionEntryBase::GetDescription() const { return _(description); } OptionEntryFlags OptionEntryBase::GetFlags() const { return flags; } void OptionEntryBase::SetValueChangedCallback(std::function callback) { this->callback = std::move(callback); } void OptionEntryBase::NotifyValueChanged() { if (callback) callback(); } void OptionEntryBoolean::LoadFromIni(std::string_view category) { value = ini->getBool(category, key, defaultValue); } void OptionEntryBoolean::SaveToIni(std::string_view category) const { ini->set(category, key, value); } void OptionEntryBoolean::SetValue(bool value) { this->value = value; this->NotifyValueChanged(); } OptionEntryType OptionEntryBoolean::GetType() const { return OptionEntryType::Boolean; } std::string_view OptionEntryBoolean::GetValueDescription() const { return value ? _("ON") : _("OFF"); } OptionEntryType OptionEntryListBase::GetType() const { return OptionEntryType::List; } std::string_view OptionEntryListBase::GetValueDescription() const { return GetListDescription(GetActiveListIndex()); } void OptionEntryEnumBase::LoadFromIni(std::string_view category) { value = ini->getInt(category, key, defaultValue); } void OptionEntryEnumBase::SaveToIni(std::string_view category) const { ini->set(category, key, value); } void OptionEntryEnumBase::SetValueInternal(int value) { this->value = value; this->NotifyValueChanged(); } void OptionEntryEnumBase::AddEntry(int value, std::string_view name) { entryValues.push_back(value); entryNames.push_back(name); } size_t OptionEntryEnumBase::GetListSize() const { return entryValues.size(); } std::string_view OptionEntryEnumBase::GetListDescription(size_t index) const { return _(entryNames[index].data()); } size_t OptionEntryEnumBase::GetActiveListIndex() const { auto iterator = c_find(entryValues, value); if (iterator == entryValues.end()) return 0; return std::distance(entryValues.begin(), iterator); } void OptionEntryEnumBase::SetActiveListIndex(size_t index) { this->value = entryValues[index]; this->NotifyValueChanged(); } void OptionEntryIntBase::LoadFromIni(std::string_view category) { value = ini->getInt(category, key, defaultValue); if (c_find(entryValues, value) == entryValues.end()) { entryValues.insert(c_lower_bound(entryValues, value), value); entryNames.clear(); } } void OptionEntryIntBase::SaveToIni(std::string_view category) const { ini->set(category, key, value); } void OptionEntryIntBase::SetValueInternal(int value) { this->value = value; this->NotifyValueChanged(); } void OptionEntryIntBase::AddEntry(int value) { entryValues.push_back(value); } size_t OptionEntryIntBase::GetListSize() const { return entryValues.size(); } std::string_view OptionEntryIntBase::GetListDescription(size_t index) const { if (entryNames.empty()) { for (auto value : entryValues) { entryNames.push_back(StrCat(value)); } } return entryNames[index].data(); } size_t OptionEntryIntBase::GetActiveListIndex() const { auto iterator = c_find(entryValues, value); if (iterator == entryValues.end()) return 0; return std::distance(entryValues.begin(), iterator); } void OptionEntryIntBase::SetActiveListIndex(size_t index) { this->value = entryValues[index]; this->NotifyValueChanged(); } std::string_view OptionCategoryBase::GetKey() const { return key; } std::string_view OptionCategoryBase::GetName() const { return _(name); } std::string_view OptionCategoryBase::GetDescription() const { return _(description); } GameModeOptions::GameModeOptions() : OptionCategoryBase("GameMode", N_("Game Mode"), N_("Game Mode Settings")) , gameMode("Game", OptionEntryFlags::NeedHellfireMpq | OptionEntryFlags::RecreateUI, N_("Game Mode"), N_("Play Diablo or Hellfire."), StartUpGameMode::Ask, { { StartUpGameMode::Diablo, N_("Diablo") }, // Ask is missing, because we want to hide it from UI-Settings. { StartUpGameMode::Hellfire, N_("Hellfire") }, }) , shareware("Shareware", OptionEntryFlags::NeedDiabloMpq | OptionEntryFlags::RecreateUI, N_("Restrict to Shareware"), N_("Makes the game compatible with the demo. Enables multiplayer with friends who don't own a full copy of Diablo."), false) { gameMode.SetValueChangedCallback(OptionGameModeChanged); shareware.SetValueChangedCallback(OptionSharewareChanged); } std::vector GameModeOptions::GetEntries() { return { &gameMode, &shareware, }; } StartUpOptions::StartUpOptions() : OptionCategoryBase("StartUp", N_("Start Up"), N_("Start Up Settings")) , diabloIntro("Diablo Intro", OptionEntryFlags::OnlyDiablo, N_("Intro"), N_("Shown Intro cinematic."), StartUpIntro::Once, { { StartUpIntro::Off, N_("OFF") }, // Once is missing, because we want to hide it from UI-Settings. { StartUpIntro::On, N_("ON") }, }) , hellfireIntro("Hellfire Intro", OptionEntryFlags::OnlyHellfire, N_("Intro"), N_("Shown Intro cinematic."), StartUpIntro::Once, { { StartUpIntro::Off, N_("OFF") }, // Once is missing, because we want to hide it from UI-Settings. { StartUpIntro::On, N_("ON") }, }) , splash("Splash", OptionEntryFlags::None, N_("Splash"), N_("Shown splash screen."), StartUpSplash::LogoAndTitleDialog, { { StartUpSplash::LogoAndTitleDialog, N_("Logo and Title Screen") }, { StartUpSplash::TitleDialog, N_("Title Screen") }, { StartUpSplash::None, N_("None") }, }) { } std::vector StartUpOptions::GetEntries() { return { &diabloIntro, &hellfireIntro, &splash, }; } DiabloOptions::DiabloOptions() : OptionCategoryBase("Diablo", N_("Diablo"), N_("Diablo specific Settings")) , lastSinglePlayerHero("LastSinglePlayerHero", OptionEntryFlags::Invisible | OptionEntryFlags::OnlyDiablo, "Sample Rate", "Remembers what singleplayer hero/save was last used.", 0) , lastMultiplayerHero("LastMultiplayerHero", OptionEntryFlags::Invisible | OptionEntryFlags::OnlyDiablo, "Sample Rate", "Remembers what multiplayer hero/save was last used.", 0) { } std::vector DiabloOptions::GetEntries() { return { &lastSinglePlayerHero, &lastMultiplayerHero, }; } HellfireOptions::HellfireOptions() : OptionCategoryBase("Hellfire", N_("Hellfire"), N_("Hellfire specific Settings")) , lastSinglePlayerHero("LastSinglePlayerHero", OptionEntryFlags::Invisible | OptionEntryFlags::OnlyHellfire, "Sample Rate", "Remembers what singleplayer hero/save was last used.", 0) , lastMultiplayerHero("LastMultiplayerHero", OptionEntryFlags::Invisible | OptionEntryFlags::OnlyHellfire, "Sample Rate", "Remembers what multiplayer hero/save was last used.", 0) { } std::vector HellfireOptions::GetEntries() { return { &lastSinglePlayerHero, &lastMultiplayerHero, }; } AudioOptions::AudioOptions() : OptionCategoryBase("Audio", N_("Audio"), N_("Audio Settings")) , soundVolume("Sound Volume", OptionEntryFlags::Invisible, "Sound Volume", "Movie and SFX volume.", VOLUME_MAX) , musicVolume("Music Volume", OptionEntryFlags::Invisible, "Music Volume", "Music Volume.", VOLUME_MAX) , walkingSound("Walking Sound", OptionEntryFlags::None, N_("Walking Sound"), N_("Player emits sound when walking."), true) , autoEquipSound("Auto Equip Sound", OptionEntryFlags::None, N_("Auto Equip Sound"), N_("Automatically equipping items on pickup emits the equipment sound."), false) , itemPickupSound("Item Pickup Sound", OptionEntryFlags::None, N_("Item Pickup Sound"), N_("Picking up items emits the items pickup sound."), false) , sampleRate("Sample Rate", OptionEntryFlags::CantChangeInGame, N_("Sample Rate"), N_("Output sample rate (Hz)."), DEFAULT_AUDIO_SAMPLE_RATE, { 22050, 44100, 48000 }) , channels("Channels", OptionEntryFlags::CantChangeInGame, N_("Channels"), N_("Number of output channels."), DEFAULT_AUDIO_CHANNELS, { 1, 2 }) , bufferSize("Buffer Size", OptionEntryFlags::CantChangeInGame, N_("Buffer Size"), N_("Buffer size (number of frames per channel)."), DEFAULT_AUDIO_BUFFER_SIZE, { 1024, 2048, 5120 }) , resamplingQuality("Resampling Quality", OptionEntryFlags::CantChangeInGame, N_("Resampling Quality"), N_("Quality of the resampler, from 0 (lowest) to 10 (highest)."), DEFAULT_AUDIO_RESAMPLING_QUALITY, { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }) { sampleRate.SetValueChangedCallback(OptionAudioChanged); channels.SetValueChangedCallback(OptionAudioChanged); bufferSize.SetValueChangedCallback(OptionAudioChanged); resamplingQuality.SetValueChangedCallback(OptionAudioChanged); resampler.SetValueChangedCallback(OptionAudioChanged); device.SetValueChangedCallback(OptionAudioChanged); } std::vector AudioOptions::GetEntries() { // clang-format off return { &soundVolume, &musicVolume, &walkingSound, &autoEquipSound, &itemPickupSound, &sampleRate, &channels, &bufferSize, &resampler, &resamplingQuality, #if SDL_VERSION_ATLEAST(2, 0, 0) &device, #endif }; // clang-format on } OptionEntryResolution::OptionEntryResolution() : OptionEntryListBase("", OptionEntryFlags::CantChangeInGame | OptionEntryFlags::RecreateUI, N_("Resolution"), N_("Affect the game's internal resolution and determine your view area. Note: This can differ from screen resolution, when Upscaling, Integer Scaling or Fit to Screen is used.")) { } void OptionEntryResolution::LoadFromIni(std::string_view category) { size = { ini->getInt(category, "Width", DEFAULT_WIDTH), ini->getInt(category, "Height", DEFAULT_HEIGHT) }; } void OptionEntryResolution::SaveToIni(std::string_view category) const { ini->set(category, "Width", size.width); ini->set(category, "Height", size.height); } void OptionEntryResolution::InvalidateList() { resolutions.clear(); } void OptionEntryResolution::CheckResolutionsAreInitialized() const { if (!resolutions.empty()) return; std::vector sizes; float scaleFactor = GetDpiScalingFactor(); // Add resolutions bool supportsAnyResolution = false; #ifdef USE_SDL1 auto *modes = SDL_ListModes(nullptr, SDL_FULLSCREEN | SDL_HWPALETTE); // SDL_ListModes returns -1 if any resolution is allowed (for example returned on 3DS) if (modes == (SDL_Rect **)-1) { supportsAnyResolution = true; } else if (modes != nullptr) { for (size_t i = 0; modes[i] != nullptr; i++) { if (modes[i]->w < modes[i]->h) { std::swap(modes[i]->w, modes[i]->h); } sizes.emplace_back(Size { static_cast(modes[i]->w * scaleFactor), static_cast(modes[i]->h * scaleFactor) }); } } #else int displayModeCount = SDL_GetNumDisplayModes(0); for (int i = 0; i < displayModeCount; i++) { SDL_DisplayMode mode; if (SDL_GetDisplayMode(0, i, &mode) != 0) { ErrSdl(); } if (mode.w < mode.h) { std::swap(mode.w, mode.h); } sizes.emplace_back(Size { static_cast(mode.w * scaleFactor), static_cast(mode.h * scaleFactor) }); } supportsAnyResolution = *sgOptions.Graphics.upscale; #endif if (supportsAnyResolution && sizes.size() == 1) { // Attempt to provide sensible options for 4:3 and the native aspect ratio const int width = sizes[0].width; const int height = sizes[0].height; const int commonHeights[] = { 480, 540, 720, 960, 1080, 1440, 2160 }; for (int commonHeight : commonHeights) { if (commonHeight > height) break; sizes.emplace_back(Size { commonHeight * 4 / 3, commonHeight }); if (commonHeight * width % height == 0) sizes.emplace_back(Size { commonHeight * width / height, commonHeight }); } } // Ensures that the ini specified resolution is present in resolution list even if it doesn't match a monitor resolution (for example if played in window mode) sizes.push_back(this->size); // Ensures that the platform's preferred default resolution is always present sizes.emplace_back(Size { DEFAULT_WIDTH, DEFAULT_HEIGHT }); // Ensures that the vanilla Diablo resolution is present on systems that would support it if (supportsAnyResolution) sizes.emplace_back(Size { 640, 480 }); #ifndef USE_SDL1 if (*sgOptions.Graphics.fitToScreen) { SDL_DisplayMode mode; if (SDL_GetDesktopDisplayMode(0, &mode) != 0) { ErrSdl(); } for (auto &size : sizes) { // Ensure that the ini specified resolution remains present in the resolution list if (size.height == this->size.height) size.width = this->size.width; else size.width = size.height * mode.w / mode.h; } } #endif // Sort by width then by height c_sort(sizes, [](const Size &x, const Size &y) -> bool { if (x.width == y.width) return x.height > y.height; return x.width > y.width; }); // Remove duplicate entries sizes.erase(std::unique(sizes.begin(), sizes.end()), sizes.end()); for (auto &size : sizes) { #ifndef USE_SDL1 if (*sgOptions.Graphics.fitToScreen) { resolutions.emplace_back(size, StrCat(size.height, "p")); continue; } #endif resolutions.emplace_back(size, StrCat(size.width, "x", size.height)); } } size_t OptionEntryResolution::GetListSize() const { CheckResolutionsAreInitialized(); return resolutions.size(); } std::string_view OptionEntryResolution::GetListDescription(size_t index) const { CheckResolutionsAreInitialized(); return resolutions[index].second; } size_t OptionEntryResolution::GetActiveListIndex() const { CheckResolutionsAreInitialized(); auto found = c_find_if(resolutions, [this](const auto &x) { return x.first == this->size; }); if (found == resolutions.end()) return 0; return std::distance(resolutions.begin(), found); } void OptionEntryResolution::SetActiveListIndex(size_t index) { size = resolutions[index].first; NotifyValueChanged(); } OptionEntryResampler::OptionEntryResampler() : OptionEntryListBase("Resampler", OptionEntryFlags::CantChangeInGame // When there are exactly 2 options there is no submenu, so we need to recreate the UI // to reflect the change in the "Resampling quality" setting visibility. | (NumResamplers == 2 ? OptionEntryFlags::RecreateUI : OptionEntryFlags::None), N_("Resampler"), N_("Audio resampler")) { } void OptionEntryResampler::LoadFromIni(std::string_view category) { std::string_view resamplerStr = ini->getString(category, key); if (!resamplerStr.empty()) { std::optional resampler = ResamplerFromString(resamplerStr); if (resampler) { resampler_ = *resampler; UpdateDependentOptions(); return; } } resampler_ = Resampler::DEVILUTIONX_DEFAULT_RESAMPLER; UpdateDependentOptions(); } void OptionEntryResampler::SaveToIni(std::string_view category) const { ini->set(category, key, ResamplerToString(resampler_)); } size_t OptionEntryResampler::GetListSize() const { return NumResamplers; } std::string_view OptionEntryResampler::GetListDescription(size_t index) const { return ResamplerToString(static_cast(index)); } size_t OptionEntryResampler::GetActiveListIndex() const { return static_cast(resampler_); } void OptionEntryResampler::SetActiveListIndex(size_t index) { resampler_ = static_cast(index); UpdateDependentOptions(); NotifyValueChanged(); } void OptionEntryResampler::UpdateDependentOptions() const { #ifdef DEVILUTIONX_RESAMPLER_SPEEX if (resampler_ == Resampler::Speex) { sgOptions.Audio.resamplingQuality.flags &= ~OptionEntryFlags::Invisible; } else { sgOptions.Audio.resamplingQuality.flags |= OptionEntryFlags::Invisible; } #endif } OptionEntryAudioDevice::OptionEntryAudioDevice() : OptionEntryListBase("Device", OptionEntryFlags::CantChangeInGame, N_("Device"), N_("Audio device")) { } void OptionEntryAudioDevice::LoadFromIni(std::string_view category) { deviceName_ = ini->getString(category, key); } void OptionEntryAudioDevice::SaveToIni(std::string_view category) const { #if SDL_VERSION_ATLEAST(2, 0, 0) ini->set(category, key, deviceName_); #endif } size_t OptionEntryAudioDevice::GetListSize() const { #if SDL_VERSION_ATLEAST(2, 0, 0) return SDL_GetNumAudioDevices(false) + 1; #else return 1; #endif } std::string_view OptionEntryAudioDevice::GetListDescription(size_t index) const { constexpr int MaxWidth = 500; std::string_view deviceName = GetDeviceName(index); if (deviceName.empty()) return "System Default"; while (GetLineWidth(deviceName, GameFont24, 1) > MaxWidth) { size_t lastSymbolIndex = FindLastUtf8Symbols(deviceName); deviceName = std::string_view(deviceName.data(), lastSymbolIndex); } return deviceName; } size_t OptionEntryAudioDevice::GetActiveListIndex() const { for (size_t i = 0; i < GetListSize(); i++) { std::string_view deviceName = GetDeviceName(i); if (deviceName == deviceName_) return i; } return 0; } void OptionEntryAudioDevice::SetActiveListIndex(size_t index) { deviceName_ = std::string { GetDeviceName(index) }; NotifyValueChanged(); } std::string_view OptionEntryAudioDevice::GetDeviceName(size_t index) const { #if SDL_VERSION_ATLEAST(2, 0, 0) if (index != 0) return SDL_GetAudioDeviceName(static_cast(index) - 1, false); #endif return ""; } GraphicsOptions::GraphicsOptions() : OptionCategoryBase("Graphics", N_("Graphics"), N_("Graphics Settings")) , fullscreen("Fullscreen", OnlyIfSupportsWindowed | OptionEntryFlags::CantChangeInGame | OptionEntryFlags::RecreateUI, N_("Fullscreen"), N_("Display the game in windowed or fullscreen mode."), true) #if !defined(USE_SDL1) || defined(__3DS__) , fitToScreen("Fit to Screen", OptionEntryFlags::CantChangeInGame | OptionEntryFlags::RecreateUI, N_("Fit to Screen"), N_("Automatically adjust the game window to your current desktop screen aspect ratio and resolution."), true) #endif #ifndef USE_SDL1 , upscale("Upscale", OptionEntryFlags::Invisible | OptionEntryFlags::CantChangeInGame | OptionEntryFlags::RecreateUI, N_("Upscale"), N_("Enables image scaling from the game resolution to your monitor resolution. Prevents changing the monitor resolution and allows window resizing."), #ifdef NXDK false #else true #endif ) , scaleQuality("Scaling Quality", OptionEntryFlags::None, N_("Scaling Quality"), N_("Enables optional filters to the output image when upscaling."), ScalingQuality::AnisotropicFiltering, { { ScalingQuality::NearestPixel, N_("Nearest Pixel") }, { ScalingQuality::BilinearFiltering, N_("Bilinear") }, { ScalingQuality::AnisotropicFiltering, N_("Anisotropic") }, }) , integerScaling("Integer Scaling", OptionEntryFlags::CantChangeInGame | OptionEntryFlags::RecreateUI, N_("Integer Scaling"), N_("Scales the image using whole number pixel ratio."), false) , vSync("Vertical Sync", OptionEntryFlags::RecreateUI #ifdef NXDK | OptionEntryFlags::Invisible #endif , N_("Vertical Sync"), N_("Forces waiting for Vertical Sync. Prevents tearing effect when drawing a frame. Disabling it can help with mouse lag on some systems."), #ifdef NXDK false #else true #endif ) #endif , gammaCorrection("Gamma Correction", OptionEntryFlags::Invisible, "Gamma Correction", "Gamma correction level.", 100) , zoom("Zoom", OptionEntryFlags::None, N_("Zoom"), N_("Zoom on when enabled."), false) , colorCycling("Color Cycling", OptionEntryFlags::None, N_("Color Cycling"), N_("Color cycling effect used for water, lava, and acid animation."), true) , alternateNestArt("Alternate nest art", OptionEntryFlags::OnlyHellfire | OptionEntryFlags::CantChangeInGame, N_("Alternate nest art"), N_("The game will use an alternative palette for Hellfire’s nest tileset."), false) #if SDL_VERSION_ATLEAST(2, 0, 0) , hardwareCursor("Hardware Cursor", OptionEntryFlags::CantChangeInGame | OptionEntryFlags::RecreateUI | (HardwareCursorSupported() ? OptionEntryFlags::None : OptionEntryFlags::Invisible), N_("Hardware Cursor"), N_("Use a hardware cursor"), HardwareCursorDefault()) , hardwareCursorForItems("Hardware Cursor For Items", OptionEntryFlags::CantChangeInGame | (HardwareCursorSupported() ? OptionEntryFlags::None : OptionEntryFlags::Invisible), N_("Hardware Cursor For Items"), N_("Use a hardware cursor for items."), false) , hardwareCursorMaxSize("Hardware Cursor Maximum Size", OptionEntryFlags::CantChangeInGame | OptionEntryFlags::RecreateUI | (HardwareCursorSupported() ? OptionEntryFlags::None : OptionEntryFlags::Invisible), N_("Hardware Cursor Maximum Size"), N_("Maximum width / height for the hardware cursor. Larger cursors fall back to software."), 128, { 0, 64, 128, 256, 512 }) #endif , limitFPS("FPS Limiter", OptionEntryFlags::None, N_("FPS Limiter"), N_("FPS is limited to avoid high CPU load. Limit considers refresh rate."), true) , showFPS("Show FPS", OptionEntryFlags::None, N_("Show FPS"), N_("Displays the FPS in the upper left corner of the screen."), false) { resolution.SetValueChangedCallback(ResizeWindow); fullscreen.SetValueChangedCallback(SetFullscreenMode); #if !defined(USE_SDL1) || defined(__3DS__) fitToScreen.SetValueChangedCallback(ResizeWindowAndUpdateResolutionOptions); #endif #ifndef USE_SDL1 scaleQuality.SetValueChangedCallback(ReinitializeTexture); integerScaling.SetValueChangedCallback(ReinitializeIntegerScale); vSync.SetValueChangedCallback(ReinitializeRenderer); #endif showFPS.SetValueChangedCallback(OptionShowFPSChanged); } std::vector GraphicsOptions::GetEntries() { // clang-format off return { &resolution, #ifndef __vita__ &fullscreen, #endif #if !defined(USE_SDL1) || defined(__3DS__) &fitToScreen, #endif #ifndef USE_SDL1 &upscale, &scaleQuality, &integerScaling, &vSync, #endif &gammaCorrection, &zoom, &limitFPS, &showFPS, &colorCycling, &alternateNestArt, #if SDL_VERSION_ATLEAST(2, 0, 0) &hardwareCursor, &hardwareCursorForItems, &hardwareCursorMaxSize, #endif }; // clang-format on } GameplayOptions::GameplayOptions() : OptionCategoryBase("Game", N_("Gameplay"), N_("Gameplay Settings")) , tickRate("Speed", OptionEntryFlags::Invisible, "Speed", "Gameplay ticks per second.", 20) , runInTown("Run in Town", OptionEntryFlags::CantChangeInMultiPlayer, N_("Run in Town"), N_("Enable jogging/fast walking in town for Diablo and Hellfire. This option was introduced in the expansion."), false) , grabInput("Grab Input", OptionEntryFlags::None, N_("Grab Input"), N_("When enabled mouse is locked to the game window."), false) , pauseOnFocusLoss("Pause Game When Window Loses Focus", OptionEntryFlags::None, N_("Pause Game When Window Loses Focus"), N_("When enabled, the game will pause when focus is lost."), true) , theoQuest("Theo Quest", OptionEntryFlags::CantChangeInGame | OptionEntryFlags::OnlyHellfire, N_("Theo Quest"), N_("Enable Little Girl quest."), false) , cowQuest("Cow Quest", OptionEntryFlags::CantChangeInGame | OptionEntryFlags::OnlyHellfire, N_("Cow Quest"), N_("Enable Jersey's quest. Lester the farmer is replaced by the Complete Nut."), false) , friendlyFire("Friendly Fire", OptionEntryFlags::CantChangeInMultiPlayer, N_("Friendly Fire"), N_("Allow arrow/spell damage between players in multiplayer even when the friendly mode is on."), true) , multiplayerFullQuests("MultiplayerFullQuests", OptionEntryFlags::CantChangeInMultiPlayer, N_("Full quests in Multiplayer"), N_("Enables the full/uncut singleplayer version of quests."), false) , testBard("Test Bard", OptionEntryFlags::CantChangeInGame | OptionEntryFlags::OnlyHellfire, N_("Test Bard"), N_("Force the Bard character type to appear in the hero selection menu."), false) , testBarbarian("Test Barbarian", OptionEntryFlags::CantChangeInGame | OptionEntryFlags::OnlyHellfire, N_("Test Barbarian"), N_("Force the Barbarian character type to appear in the hero selection menu."), false) , experienceBar("Experience Bar", OptionEntryFlags::None, N_("Experience Bar"), N_("Experience Bar is added to the UI at the bottom of the screen."), false) , showItemGraphicsInStores("Show Item Graphics in Stores", OptionEntryFlags::None, N_("Show Item Graphics in Stores"), N_("Show item graphics to the left of item descriptions in store menus."), false) , showHealthValues("Show health values", OptionEntryFlags::None, N_("Show health values"), N_("Displays current / max health value on health globe."), false) , showManaValues("Show mana values", OptionEntryFlags::None, N_("Show mana values"), N_("Displays current / max mana value on mana globe."), false) , enemyHealthBar("Enemy Health Bar", OptionEntryFlags::None, N_("Enemy Health Bar"), N_("Enemy Health Bar is displayed at the top of the screen."), false) , autoGoldPickup("Auto Gold Pickup", OptionEntryFlags::None, N_("Auto Gold Pickup"), N_("Gold is automatically collected when in close proximity to the player."), false) , autoElixirPickup("Auto Elixir Pickup", OptionEntryFlags::None, N_("Auto Elixir Pickup"), N_("Elixirs are automatically collected when in close proximity to the player."), false) , autoOilPickup("Auto Oil Pickup", OptionEntryFlags::OnlyHellfire, N_("Auto Oil Pickup"), N_("Oils are automatically collected when in close proximity to the player."), false) , autoPickupInTown("Auto Pickup in Town", OptionEntryFlags::None, N_("Auto Pickup in Town"), N_("Automatically pickup items in town."), false) , adriaRefillsMana("Adria Refills Mana", OptionEntryFlags::None, N_("Adria Refills Mana"), N_("Adria will refill your mana when you visit her shop."), false) , autoEquipWeapons("Auto Equip Weapons", OptionEntryFlags::None, N_("Auto Equip Weapons"), N_("Weapons will be automatically equipped on pickup or purchase if enabled."), true) , autoEquipArmor("Auto Equip Armor", OptionEntryFlags::None, N_("Auto Equip Armor"), N_("Armor will be automatically equipped on pickup or purchase if enabled."), false) , autoEquipHelms("Auto Equip Helms", OptionEntryFlags::None, N_("Auto Equip Helms"), N_("Helms will be automatically equipped on pickup or purchase if enabled."), false) , autoEquipShields("Auto Equip Shields", OptionEntryFlags::None, N_("Auto Equip Shields"), N_("Shields will be automatically equipped on pickup or purchase if enabled."), false) , autoEquipJewelry("Auto Equip Jewelry", OptionEntryFlags::None, N_("Auto Equip Jewelry"), N_("Jewelry will be automatically equipped on pickup or purchase if enabled."), false) , randomizeQuests("Randomize Quests", OptionEntryFlags::CantChangeInGame, N_("Randomize Quests"), N_("Randomly selecting available quests for new games."), true) , showMonsterType("Show Monster Type", OptionEntryFlags::None, N_("Show Monster Type"), N_("Hovering over a monster will display the type of monster in the description box in the UI."), false) , showItemLabels("Show Item Labels", OptionEntryFlags::None, N_("Show Item Labels"), N_("Show labels for items on the ground when enabled."), false) , autoRefillBelt("Auto Refill Belt", OptionEntryFlags::None, N_("Auto Refill Belt"), N_("Refill belt from inventory when belt item is consumed."), false) , disableCripplingShrines("Disable Crippling Shrines", OptionEntryFlags::None, N_("Disable Crippling Shrines"), N_("When enabled Cauldrons, Fascinating Shrines, Goat Shrines, Ornate Shrines, Sacred Shrines and Murphy's Shrines are not able to be clicked on and labeled as disabled."), false) , quickCast("Quick Cast", OptionEntryFlags::None, N_("Quick Cast"), N_("Spell hotkeys instantly cast the spell, rather than switching the readied spell."), false) , numHealPotionPickup("Heal Potion Pickup", OptionEntryFlags::None, N_("Heal Potion Pickup"), N_("Number of Healing potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 }) , numFullHealPotionPickup("Full Heal Potion Pickup", OptionEntryFlags::None, N_("Full Heal Potion Pickup"), N_("Number of Full Healing potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 }) , numManaPotionPickup("Mana Potion Pickup", OptionEntryFlags::None, N_("Mana Potion Pickup"), N_("Number of Mana potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 }) , numFullManaPotionPickup("Full Mana Potion Pickup", OptionEntryFlags::None, N_("Full Mana Potion Pickup"), N_("Number of Full Mana potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 }) , numRejuPotionPickup("Rejuvenation Potion Pickup", OptionEntryFlags::None, N_("Rejuvenation Potion Pickup"), N_("Number of Rejuvenation potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 }) , numFullRejuPotionPickup("Full Rejuvenation Potion Pickup", OptionEntryFlags::None, N_("Full Rejuvenation Potion Pickup"), N_("Number of Full Rejuvenation potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 }) , enableFloatingNumbers("Enable floating numbers", OptionEntryFlags::None, N_("Enable floating numbers"), N_("Enables floating numbers on gaining XP / dealing damage etc."), FloatingNumbers::Off, { { FloatingNumbers::Off, N_("Off") }, { FloatingNumbers::Random, N_("Random Angles") }, { FloatingNumbers::Vertical, N_("Vertical Only") }, }) , skipLoadingScreenThresholdMs("Skip loading screen threshold, ms", OptionEntryFlags::Invisible, "", "", 0) { grabInput.SetValueChangedCallback(OptionGrabInputChanged); experienceBar.SetValueChangedCallback(OptionExperienceBarChanged); enemyHealthBar.SetValueChangedCallback(OptionEnemyHealthBarChanged); } std::vector GameplayOptions::GetEntries() { return { &tickRate, &friendlyFire, &multiplayerFullQuests, &randomizeQuests, &theoQuest, &cowQuest, &runInTown, &quickCast, &testBard, &testBarbarian, &experienceBar, &showItemGraphicsInStores, &showHealthValues, &showManaValues, &enemyHealthBar, &showMonsterType, &showItemLabels, &enableFloatingNumbers, &autoRefillBelt, &autoEquipWeapons, &autoEquipArmor, &autoEquipHelms, &autoEquipShields, &autoEquipJewelry, &autoGoldPickup, &autoElixirPickup, &autoOilPickup, &numHealPotionPickup, &numFullHealPotionPickup, &numManaPotionPickup, &numFullManaPotionPickup, &numRejuPotionPickup, &numFullRejuPotionPickup, &autoPickupInTown, &disableCripplingShrines, &adriaRefillsMana, &grabInput, &pauseOnFocusLoss, &skipLoadingScreenThresholdMs, }; } ControllerOptions::ControllerOptions() : OptionCategoryBase("Controller", N_("Controller"), N_("Controller Settings")) { } std::vector ControllerOptions::GetEntries() { return {}; } NetworkOptions::NetworkOptions() : OptionCategoryBase("Network", N_("Network"), N_("Network Settings")) , port("Port", OptionEntryFlags::Invisible, "Port", "What network port to use.", 6112) { } std::vector NetworkOptions::GetEntries() { return { &port, }; } ChatOptions::ChatOptions() : OptionCategoryBase("NetMsg", N_("Chat"), N_("Chat Settings")) { } std::vector ChatOptions::GetEntries() { return {}; } OptionEntryLanguageCode::OptionEntryLanguageCode() : OptionEntryListBase("Code", OptionEntryFlags::CantChangeInGame | OptionEntryFlags::RecreateUI, N_("Language"), N_("Define what language to use in game.")) { } void OptionEntryLanguageCode::LoadFromIni(std::string_view category) { ini->getUtf8Buf(category, key, szCode, sizeof(szCode)); if (szCode[0] != '\0' && HasTranslation(szCode)) { // User preferred language is available return; } // Might be a first run or the user has attempted to load a translation that doesn't exist via manual ini edit. Try // find a best fit from the platform locale information. std::vector locales = GetLocales(); // So that the correct language is shown in the settings menu for users with US english set as a preferred language // we need to replace the "en_US" locale code with the neutral string "en" as expected by the available options std::replace(locales.begin(), locales.end(), std::string { "en_US" }, std::string { "en" }); // Insert non-regional locale codes after the last regional variation so we fallback to neutral translations if no // regional translation exists that meets user preferences. for (auto localeIter = locales.rbegin(); localeIter != locales.rend(); localeIter++) { auto regionSeparator = localeIter->find('_'); if (regionSeparator != std::string::npos) { std::string neutralLocale = localeIter->substr(0, regionSeparator); if (std::find(locales.rbegin(), localeIter, neutralLocale) == localeIter) { localeIter = std::make_reverse_iterator(locales.insert(localeIter.base(), neutralLocale)); } } } LogVerbose("Found user preferred locales: {}", fmt::join(locales, ", ")); for (const auto &locale : locales) { LogVerbose("Trying to load translation: {}", locale); if (HasTranslation(locale)) { LogVerbose("Best match locale: {}", locale); CopyUtf8(szCode, locale, sizeof(szCode)); return; } } LogVerbose("No suitable translation found"); strcpy(szCode, "en"); } void OptionEntryLanguageCode::SaveToIni(std::string_view category) const { ini->set(category, key, szCode); } void OptionEntryLanguageCode::CheckLanguagesAreInitialized() const { if (!languages.empty()) return; // Add well-known supported languages languages.emplace_back("bg", "Български"); languages.emplace_back("cs", "Čeština"); languages.emplace_back("da", "Dansk"); languages.emplace_back("de", "Deutsch"); languages.emplace_back("el", "Ελληνικά"); languages.emplace_back("en", "English"); languages.emplace_back("es", "Español"); languages.emplace_back("et", "Eesti"); languages.emplace_back("fr", "Français"); languages.emplace_back("hr", "Hrvatski"); languages.emplace_back("hu", "Magyar"); languages.emplace_back("it", "Italiano"); if (HaveExtraFonts()) { languages.emplace_back("ja", "日本語"); languages.emplace_back("ko", "한국어"); } languages.emplace_back("pl", "Polski"); languages.emplace_back("pt_BR", "Português do Brasil"); languages.emplace_back("ro", "Română"); languages.emplace_back("ru", "Русский"); languages.emplace_back("sv", "Svenska"); languages.emplace_back("tr", "Türkçe"); languages.emplace_back("uk", "Українська"); if (HaveExtraFonts()) { languages.emplace_back("zh_CN", "汉语"); languages.emplace_back("zh_TW", "漢語"); } // Ensures that the ini specified language is present in languages list even if unknown (for example if someone starts to translate a new language) if (c_find_if(languages, [this](const auto &x) { return x.first == this->szCode; }) == languages.end()) { languages.emplace_back(szCode, szCode); } } size_t OptionEntryLanguageCode::GetListSize() const { CheckLanguagesAreInitialized(); return languages.size(); } std::string_view OptionEntryLanguageCode::GetListDescription(size_t index) const { CheckLanguagesAreInitialized(); return languages[index].second; } size_t OptionEntryLanguageCode::GetActiveListIndex() const { CheckLanguagesAreInitialized(); auto found = c_find_if(languages, [this](const auto &x) { return x.first == this->szCode; }); if (found == languages.end()) return 0; return std::distance(languages.begin(), found); } void OptionEntryLanguageCode::SetActiveListIndex(size_t index) { CopyUtf8(szCode, languages[index].first, sizeof(szCode)); NotifyValueChanged(); } LanguageOptions::LanguageOptions() : OptionCategoryBase("Language", N_("Language"), N_("Language Settings")) { code.SetValueChangedCallback(OptionLanguageCodeChanged); } std::vector LanguageOptions::GetEntries() { return { &code, }; } KeymapperOptions::KeymapperOptions() : OptionCategoryBase("Keymapping", N_("Keymapping"), N_("Keymapping Settings")) { // Insert all supported keys: a-z, 0-9 and F1-F24. keyIDToKeyName.reserve(('Z' - 'A' + 1) + ('9' - '0' + 1) + 12); for (char c = 'A'; c <= 'Z'; ++c) { keyIDToKeyName.emplace(c, std::string(1, c)); } for (char c = '0'; c <= '9'; ++c) { keyIDToKeyName.emplace(c, std::string(1, c)); } for (int i = 0; i < 12; ++i) { keyIDToKeyName.emplace(SDLK_F1 + i, StrCat("F", i + 1)); } for (int i = 0; i < 12; ++i) { keyIDToKeyName.emplace(SDLK_F13 + i, StrCat("F", i + 13)); } keyIDToKeyName.emplace(SDLK_KP_0, "KEYPADNUM 0"); for (int i = 0; i < 9; i++) { keyIDToKeyName.emplace(SDLK_KP_1 + i, StrCat("KEYPADNUM ", i + 1)); } keyIDToKeyName.emplace(SDLK_LALT, "LALT"); keyIDToKeyName.emplace(SDLK_RALT, "RALT"); keyIDToKeyName.emplace(SDLK_SPACE, "SPACE"); keyIDToKeyName.emplace(SDLK_RCTRL, "RCONTROL"); keyIDToKeyName.emplace(SDLK_LCTRL, "LCONTROL"); keyIDToKeyName.emplace(SDLK_PRINTSCREEN, "PRINT"); keyIDToKeyName.emplace(SDLK_PAUSE, "PAUSE"); keyIDToKeyName.emplace(SDLK_TAB, "TAB"); keyIDToKeyName.emplace(SDL_BUTTON_MIDDLE | KeymapperMouseButtonMask, "MMOUSE"); keyIDToKeyName.emplace(SDL_BUTTON_X1 | KeymapperMouseButtonMask, "X1MOUSE"); keyIDToKeyName.emplace(SDL_BUTTON_X2 | KeymapperMouseButtonMask, "X2MOUSE"); keyIDToKeyName.emplace(MouseScrollUpButton, "SCROLLUPMOUSE"); keyIDToKeyName.emplace(MouseScrollDownButton, "SCROLLDOWNMOUSE"); keyIDToKeyName.emplace(MouseScrollLeftButton, "SCROLLLEFTMOUSE"); keyIDToKeyName.emplace(MouseScrollRightButton, "SCROLLRIGHTMOUSE"); keyIDToKeyName.emplace(SDLK_BACKQUOTE, "`"); keyIDToKeyName.emplace(SDLK_LEFTBRACKET, "["); keyIDToKeyName.emplace(SDLK_RIGHTBRACKET, "]"); keyIDToKeyName.emplace(SDLK_BACKSLASH, "\\"); keyIDToKeyName.emplace(SDLK_SEMICOLON, ";"); keyIDToKeyName.emplace(SDLK_QUOTE, "'"); keyIDToKeyName.emplace(SDLK_COMMA, ","); keyIDToKeyName.emplace(SDLK_PERIOD, "."); keyIDToKeyName.emplace(SDLK_SLASH, "/"); keyIDToKeyName.emplace(SDLK_BACKSPACE, "BACKSPACE"); keyIDToKeyName.emplace(SDLK_CAPSLOCK, "CAPSLOCK"); keyIDToKeyName.emplace(SDLK_SCROLLLOCK, "SCROLLLOCK"); keyIDToKeyName.emplace(SDLK_INSERT, "INSERT"); keyIDToKeyName.emplace(SDLK_DELETE, "DELETE"); keyIDToKeyName.emplace(SDLK_HOME, "HOME"); keyIDToKeyName.emplace(SDLK_END, "END"); keyIDToKeyName.emplace(SDLK_KP_DIVIDE, "KEYPAD /"); keyIDToKeyName.emplace(SDLK_KP_MULTIPLY, "KEYPAD *"); keyIDToKeyName.emplace(SDLK_KP_ENTER, "KEYPAD ENTER"); keyIDToKeyName.emplace(SDLK_KP_PERIOD, "KEYPAD DECIMAL"); keyNameToKeyID.reserve(keyIDToKeyName.size()); for (const auto &[key, value] : keyIDToKeyName) { keyNameToKeyID.emplace(value, key); } } std::vector KeymapperOptions::GetEntries() { std::vector entries; for (Action &action : actions) { entries.push_back(&action); } return entries; } KeymapperOptions::Action::Action(std::string_view key, const char *name, const char *description, uint32_t defaultKey, std::function actionPressed, std::function actionReleased, std::function enable, unsigned index) : OptionEntryBase(key, OptionEntryFlags::None, name, description) , defaultKey(defaultKey) , actionPressed(std::move(actionPressed)) , actionReleased(std::move(actionReleased)) , enable(std::move(enable)) , dynamicIndex(index) { if (index != 0) { dynamicKey = fmt::format(fmt::runtime(std::string_view(key.data(), key.size())), index); this->key = dynamicKey; } } std::string_view KeymapperOptions::Action::GetName() const { if (dynamicIndex == 0) return _(name); dynamicName = fmt::format(fmt::runtime(_(name)), dynamicIndex); return dynamicName; } void KeymapperOptions::Action::LoadFromIni(std::string_view category) { const std::span iniValues = ini->get(category, key); if (iniValues.empty()) { SetValue(defaultKey); return; // Use the default key if no key has been set. } const std::string_view iniValue = iniValues.back().value; if (iniValue.empty()) { SetValue(SDLK_UNKNOWN); return; } auto keyIt = sgOptions.Keymapper.keyNameToKeyID.find(iniValue); if (keyIt == sgOptions.Keymapper.keyNameToKeyID.end()) { // Use the default key if the key is unknown. Log("Keymapper: unknown key '{}'", iniValue); SetValue(defaultKey); return; } // Store the key in action.key and in the map so we can save() the // actions while keeping the same order as they have been added. SetValue(keyIt->second); } void KeymapperOptions::Action::SaveToIni(std::string_view category) const { if (boundKey == SDLK_UNKNOWN) { // Just add an empty config entry if the action is unbound. ini->set(category, key, std::string {}); return; } auto keyNameIt = sgOptions.Keymapper.keyIDToKeyName.find(boundKey); if (keyNameIt == sgOptions.Keymapper.keyIDToKeyName.end()) { LogVerbose("Keymapper: no name found for key {} bound to {}", boundKey, key); return; } ini->set(category, key, keyNameIt->second); } std::string_view KeymapperOptions::Action::GetValueDescription() const { if (boundKey == SDLK_UNKNOWN) return ""; auto keyNameIt = sgOptions.Keymapper.keyIDToKeyName.find(boundKey); if (keyNameIt == sgOptions.Keymapper.keyIDToKeyName.end()) { return ""; } return keyNameIt->second; } bool KeymapperOptions::Action::SetValue(int value) { if (value != SDLK_UNKNOWN && sgOptions.Keymapper.keyIDToKeyName.find(value) == sgOptions.Keymapper.keyIDToKeyName.end()) { // Ignore invalid key values return false; } // Remove old key if (boundKey != SDLK_UNKNOWN) { sgOptions.Keymapper.keyIDToAction.erase(boundKey); boundKey = SDLK_UNKNOWN; } // Add new key if (value != SDLK_UNKNOWN) { auto it = sgOptions.Keymapper.keyIDToAction.find(value); if (it != sgOptions.Keymapper.keyIDToAction.end()) { // Warn about overwriting keys. Log("Keymapper: key '{}' is already bound to action '{}', overwriting", value, it->second.get().name); it->second.get().boundKey = SDLK_UNKNOWN; } sgOptions.Keymapper.keyIDToAction.insert_or_assign(value, *this); boundKey = value; } return true; } void KeymapperOptions::AddAction(std::string_view key, const char *name, const char *description, uint32_t defaultKey, std::function actionPressed, std::function actionReleased, std::function enable, unsigned index) { actions.emplace_front(key, name, description, defaultKey, std::move(actionPressed), std::move(actionReleased), std::move(enable), index); } void KeymapperOptions::CommitActions() { actions.reverse(); } void KeymapperOptions::KeyPressed(uint32_t key) const { if (key >= SDLK_a && key <= SDLK_z) { key -= 'a' - 'A'; } auto it = keyIDToAction.find(key); if (it == keyIDToAction.end()) return; // Ignore unmapped keys. const Action &action = it->second.get(); // Check that the action can be triggered and that the chat textbox is not // open. if (!action.actionPressed || (action.enable && !action.enable()) || ChatFlag) return; action.actionPressed(); } void KeymapperOptions::KeyReleased(SDL_Keycode key) const { if (key >= SDLK_a && key <= SDLK_z) { key = static_cast(static_cast(key) - ('a' - 'A')); } auto it = keyIDToAction.find(key); if (it == keyIDToAction.end()) return; // Ignore unmapped keys. const Action &action = it->second.get(); // Check that the action can be triggered and that the chat or gold textbox is not // open. If either of those textboxes are open, only return if the key can be used for entry into the box if (!action.actionReleased || (action.enable && !action.enable()) || ((ChatFlag && IsTextEntryKey(key)) || (DropGoldFlag && IsNumberEntryKey(key)))) return; action.actionReleased(); } bool KeymapperOptions::IsTextEntryKey(SDL_Keycode vkey) const { return IsAnyOf(vkey, SDLK_ESCAPE, SDLK_RETURN, SDLK_KP_ENTER, SDLK_BACKSPACE, SDLK_DOWN, SDLK_UP) || (vkey >= SDLK_SPACE && vkey <= SDLK_z); } bool KeymapperOptions::IsNumberEntryKey(SDL_Keycode vkey) const { return ((vkey >= SDLK_0 && vkey <= SDLK_9) || vkey == SDLK_BACKSPACE); } std::string_view KeymapperOptions::KeyNameForAction(std::string_view actionName) const { for (const Action &action : actions) { if (action.key == actionName && action.boundKey != SDLK_UNKNOWN) { return action.GetValueDescription(); } } return ""; } uint32_t KeymapperOptions::KeyForAction(std::string_view actionName) const { for (const Action &action : actions) { if (action.key == actionName && action.boundKey != SDLK_UNKNOWN) { return action.boundKey; } } return SDLK_UNKNOWN; } PadmapperOptions::PadmapperOptions() : OptionCategoryBase("Padmapping", N_("Padmapping"), N_("Padmapping Settings")) , buttonToButtonName { { /*ControllerButton_NONE*/ {}, /*ControllerButton_IGNORE*/ {}, /*ControllerButton_AXIS_TRIGGERLEFT*/ "LT", /*ControllerButton_AXIS_TRIGGERRIGHT*/ "RT", /*ControllerButton_BUTTON_A*/ "A", /*ControllerButton_BUTTON_B*/ "B", /*ControllerButton_BUTTON_X*/ "X", /*ControllerButton_BUTTON_Y*/ "Y", /*ControllerButton_BUTTON_LEFTSTICK*/ "LS", /*ControllerButton_BUTTON_RIGHTSTICK*/ "RS", /*ControllerButton_BUTTON_LEFTSHOULDER*/ "LB", /*ControllerButton_BUTTON_RIGHTSHOULDER*/ "RB", /*ControllerButton_BUTTON_START*/ "Start", /*ControllerButton_BUTTON_BACK*/ "Select", /*ControllerButton_BUTTON_DPAD_UP*/ "Up", /*ControllerButton_BUTTON_DPAD_DOWN*/ "Down", /*ControllerButton_BUTTON_DPAD_LEFT*/ "Left", /*ControllerButton_BUTTON_DPAD_RIGHT*/ "Right", } } { buttonNameToButton.reserve(buttonToButtonName.size()); for (size_t i = 0; i < buttonToButtonName.size(); ++i) { buttonNameToButton.emplace(buttonToButtonName[i], static_cast(i)); } } std::vector PadmapperOptions::GetEntries() { std::vector entries; for (Action &action : actions) { entries.push_back(&action); } return entries; } PadmapperOptions::Action::Action(std::string_view key, const char *name, const char *description, ControllerButtonCombo defaultInput, std::function actionPressed, std::function actionReleased, std::function enable, unsigned index) : OptionEntryBase(key, OptionEntryFlags::None, name, description) , defaultInput(defaultInput) , actionPressed(std::move(actionPressed)) , actionReleased(std::move(actionReleased)) , enable(std::move(enable)) , dynamicIndex(index) { if (index != 0) { dynamicKey = fmt::format(fmt::runtime(std::string_view(key.data(), key.size())), index); this->key = dynamicKey; } } std::string_view PadmapperOptions::Action::GetName() const { if (dynamicIndex == 0) return _(name); dynamicName = fmt::format(fmt::runtime(_(name)), dynamicIndex); return dynamicName; } void PadmapperOptions::Action::LoadFromIni(std::string_view category) { const std::span iniValues = ini->get(category, key); if (iniValues.empty()) { SetValue(defaultInput); return; // Use the default button combo if no mapping has been set. } const std::string_view iniValue = iniValues.back().value; std::string modName; std::string buttonName; auto parts = SplitByChar(iniValue, '+'); auto it = parts.begin(); if (it == parts.end()) { SetValue(ControllerButtonCombo {}); return; } buttonName = std::string(*it); if (++it != parts.end()) { modName = std::move(buttonName); buttonName = std::string(*it); } ControllerButtonCombo input {}; if (!modName.empty()) { auto modifierIt = sgOptions.Padmapper.buttonNameToButton.find(modName); if (modifierIt == sgOptions.Padmapper.buttonNameToButton.end()) { // Use the default button combo if the modifier name is unknown. LogWarn("Padmapper: unknown button '{}'", modName); SetValue(defaultInput); return; } input.modifier = modifierIt->second; } auto buttonIt = sgOptions.Padmapper.buttonNameToButton.find(buttonName); if (buttonIt == sgOptions.Padmapper.buttonNameToButton.end()) { // Use the default button combo if the button name is unknown. LogWarn("Padmapper: unknown button '{}'", buttonName); SetValue(defaultInput); return; } input.button = buttonIt->second; // Store the input in action.boundInput and in the map so we can save() // the actions while keeping the same order as they have been added. SetValue(input); } void PadmapperOptions::Action::SaveToIni(std::string_view category) const { if (boundInput.button == ControllerButton_NONE) { // Just add an empty config entry if the action is unbound. ini->set(category, key, ""); return; } std::string inputName = sgOptions.Padmapper.buttonToButtonName[static_cast(boundInput.button)]; if (inputName.empty()) { LogVerbose("Padmapper: no name found for button {} bound to {}", static_cast(boundInput.button), key); return; } if (boundInput.modifier != ControllerButton_NONE) { const std::string &modifierName = sgOptions.Padmapper.buttonToButtonName[static_cast(boundInput.modifier)]; if (modifierName.empty()) { LogVerbose("Padmapper: no name found for modifier button {} bound to {}", static_cast(boundInput.button), key); return; } inputName = StrCat(modifierName, "+", inputName); } ini->set(category, key, inputName.data()); } void PadmapperOptions::Action::UpdateValueDescription() const { boundInputDescriptionType = GamepadType; if (boundInput.button == ControllerButton_NONE) { boundInputDescription = ""; boundInputShortDescription = ""; return; } std::string_view buttonName = ToString(boundInput.button); if (boundInput.modifier == ControllerButton_NONE) { boundInputDescription = std::string(buttonName); boundInputShortDescription = std::string(Shorten(buttonName)); return; } std::string_view modifierName = ToString(boundInput.modifier); boundInputDescription = StrCat(modifierName, "+", buttonName); boundInputShortDescription = StrCat(Shorten(modifierName), "+", Shorten(buttonName)); } std::string_view PadmapperOptions::Action::Shorten(std::string_view buttonName) const { size_t index = 0; size_t chars = 0; while (index < buttonName.size()) { if (!IsTrailUtf8CodeUnit(buttonName[index])) chars++; if (chars == 3) break; index++; } return std::string_view(buttonName.data(), index); } std::string_view PadmapperOptions::Action::GetValueDescription() const { return GetValueDescription(false); } std::string_view PadmapperOptions::Action::GetValueDescription(bool useShortName) const { if (GamepadType != boundInputDescriptionType) UpdateValueDescription(); return useShortName ? boundInputShortDescription : boundInputDescription; } bool PadmapperOptions::Action::SetValue(ControllerButtonCombo value) { if (boundInput.button != ControllerButton_NONE) boundInput = {}; if (value.button != ControllerButton_NONE) boundInput = value; UpdateValueDescription(); return true; } void PadmapperOptions::AddAction(std::string_view key, const char *name, const char *description, ControllerButtonCombo defaultInput, std::function actionPressed, std::function actionReleased, std::function enable, unsigned index) { if (committed) return; actions.emplace_front(key, name, description, defaultInput, std::move(actionPressed), std::move(actionReleased), std::move(enable), index); } void PadmapperOptions::CommitActions() { if (committed) return; actions.reverse(); committed = true; } void PadmapperOptions::ButtonPressed(ControllerButton button) { const Action *action = FindAction(button); if (action == nullptr) return; if (IsMovementHandlerActive() && CanDeferToMovementHandler(*action)) return; if (action->actionPressed) action->actionPressed(); SuppressedButton = action->boundInput.modifier; buttonToReleaseAction[static_cast(button)] = action; } void PadmapperOptions::ButtonReleased(ControllerButton button, bool invokeAction) { if (invokeAction) { const Action *action = buttonToReleaseAction[static_cast(button)]; if (action == nullptr) return; // Ignore unmapped buttons. // Check that the action can be triggered. if (action->actionReleased && (!action->enable || action->enable())) action->actionReleased(); } buttonToReleaseAction[static_cast(button)] = nullptr; } void PadmapperOptions::ReleaseAllActiveButtons() { for (auto *action : buttonToReleaseAction) { if (action == nullptr) continue; ControllerButton button = action->boundInput.button; ButtonReleased(button, true); } } bool PadmapperOptions::IsActive(std::string_view actionName) const { for (const Action &action : actions) { if (action.key != actionName) continue; const Action *releaseAction = buttonToReleaseAction[static_cast(action.boundInput.button)]; return releaseAction != nullptr && releaseAction->key == actionName; } return false; } std::string_view PadmapperOptions::ActionNameTriggeredByButtonEvent(ControllerButtonEvent ctrlEvent) const { if (!gbRunGame) return ""; if (!ctrlEvent.up) { const Action *pressAction = FindAction(ctrlEvent.button); return pressAction != nullptr ? pressAction->key : ""; } const Action *releaseAction = buttonToReleaseAction[static_cast(ctrlEvent.button)]; if (releaseAction == nullptr) return ""; return releaseAction->key; } std::string_view PadmapperOptions::InputNameForAction(std::string_view actionName, bool useShortName) const { for (const Action &action : actions) { if (action.key == actionName && action.boundInput.button != ControllerButton_NONE) { return action.GetValueDescription(useShortName); } } return ""; } ControllerButtonCombo PadmapperOptions::ButtonComboForAction(std::string_view actionName) const { for (const auto &action : actions) { if (action.key == actionName && action.boundInput.button != ControllerButton_NONE) { return action.boundInput; } } return ControllerButton_NONE; } const PadmapperOptions::Action *PadmapperOptions::FindAction(ControllerButton button) const { // To give preference to button combinations, // first pass ignores mappings where no modifier is bound for (const Action &action : actions) { ControllerButtonCombo combo = action.boundInput; if (combo.modifier == ControllerButton_NONE) continue; if (button != combo.button) continue; if (!IsControllerButtonPressed(combo.modifier)) continue; if (action.enable && !action.enable()) continue; return &action; } for (const Action &action : actions) { ControllerButtonCombo combo = action.boundInput; if (combo.modifier != ControllerButton_NONE) continue; if (button != combo.button) continue; if (action.enable && !action.enable()) continue; return &action; } return nullptr; } bool PadmapperOptions::CanDeferToMovementHandler(const Action &action) const { if (action.boundInput.modifier != ControllerButton_NONE) return false; if (SpellSelectFlag) { const std::string_view prefix { "QuickSpell" }; const std::string_view key { action.key }; if (key.size() >= prefix.size()) { const std::string_view truncated { key.data(), prefix.size() }; if (truncated == prefix) return false; } } return IsAnyOf(action.boundInput.button, ControllerButton_BUTTON_DPAD_UP, ControllerButton_BUTTON_DPAD_DOWN, ControllerButton_BUTTON_DPAD_LEFT, ControllerButton_BUTTON_DPAD_RIGHT); } namespace { #ifdef DEVILUTIONX_RESAMPLER_SPEEX constexpr char ResamplerSpeex[] = "Speex"; #endif #ifdef DVL_AULIB_SUPPORTS_SDL_RESAMPLER constexpr char ResamplerSDL[] = "SDL"; #endif } // namespace std::string_view ResamplerToString(Resampler resampler) { switch (resampler) { #ifdef DEVILUTIONX_RESAMPLER_SPEEX case Resampler::Speex: return ResamplerSpeex; #endif #ifdef DVL_AULIB_SUPPORTS_SDL_RESAMPLER case Resampler::SDL: return ResamplerSDL; #endif default: return ""; } } std::optional ResamplerFromString(std::string_view resampler) { #ifdef DEVILUTIONX_RESAMPLER_SPEEX if (resampler == ResamplerSpeex) return Resampler::Speex; #endif #ifdef DVL_AULIB_SUPPORTS_SDL_RESAMPLER if (resampler == ResamplerSDL) return Resampler::SDL; #endif return std::nullopt; } } // namespace devilution