Skip to content

Commit

Permalink
Merge pull request #3 from JonLiuFYI/master
Browse files Browse the repository at this point in the history
Linux+Proton support
  • Loading branch information
biggestcookie authored May 23, 2023
2 parents f5fe3b6 + dd1a287 commit d6acce9
Show file tree
Hide file tree
Showing 10 changed files with 199 additions and 56 deletions.
2 changes: 2 additions & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
biggestcookie
JonLiuFYI
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changelog

## 1.2 (2023-05-15)
* Added Linux support

## 1.1 (2020-07-04)
* Added main menu
* Added saving and loading settings

## 1.0 (2020-06-24)
Initial release
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Borderlands Sensitivity Changer

### [Download it here!](https://github.com/biggestcookie/borderlands-sensitivity-changer/releases/latest)
### [Download for Windows and Linux+Proton here!](https://github.com/biggestcookie/borderlands-sensitivity-changer/releases/latest)

Borderlands 2 and Borderlands: The Pre-Sequel have a minimum sensitivity value of 10, which [can be frustrating when the minimum sensitivity is still too high](https://www.google.com/search?q=borderlands+2+sensitivity+too+high). This program helps you easily change this value yourself lower than the minimum, or any value of your choosing.

Expand All @@ -14,7 +14,7 @@ Your mouse sensitivity, among other user settings, are stored somewhere in a fil

The built application can be found in the [releases](https://github.com/biggestcookie/borderlands-sensitivity-changer/releases) of this repository. Simply download the application to any location and run it. Make sure you have run the game and have changed some settings at least once before launching.

Currently there is only Windows support.
This app runs on Windows and Linux. On Linux, it will only work on the Windows version of the game running through Proton, and not the Linux-native port.

### Demonstration

Expand Down Expand Up @@ -58,7 +58,7 @@ The 'Uncapped Pause Menu Settings' mod for [Borderlands 2](https://www.nexusmods

### Requirements

[Python 3.5+](https://www.python.org/downloads/)
[Python 3.10+](https://www.python.org/downloads/)

Install required packages using `pip install -r requirements.txt`

Expand Down
32 changes: 23 additions & 9 deletions config.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,40 @@
from enum import Enum
import json
import os
from pathlib import Path
import platform
import sys
from typing import Any

import __main__
from game_choice import Game_Choice
import user_os
from util import try_input


class Game_Choice(Enum):
BL2 = "Borderlands 2"
TPS = "Borderlands The Pre-Sequel"


class Config:
game_choice: Game_Choice
config_data: Any
config_path = f"{os.path.dirname(os.path.abspath(__main__.__file__))}\\config.json"
_user_os: user_os.UserOS

if getattr(sys, 'frozen', False): # if running as PyInstaller onefile
_app_dir = Path(sys.executable).resolve().parent
else:
_app_dir = Path(__main__.__file__).resolve().parent
config_path = _app_dir / "config.json"

def __init__(self):
text = [
"Which game are you changing sensitivity for?",
*[f"{i + 1} - {choice.value}" for i, choice in enumerate(Game_Choice)],
]
self.game_choice = try_input(
self.game_choice: Game_Choice = try_input(
self.__input_to_game_choice, text=text, prompt="Type # and press enter: "
)

CurrentUserOS = (
user_os.Windows if platform.system() == "Windows" else user_os.Linux
)
self._user_os = CurrentUserOS(self.game_choice)

self.config_data = self.load_config()
self.save_config()

Expand Down Expand Up @@ -52,6 +62,10 @@ def set_data(self, key: str, value: Any):
self.config_data[self.game_choice.value][key] = value
self.save_config()

@property
def user_os(self) -> user_os.UserOS:
return self._user_os

@staticmethod
def __input_to_game_choice(input: str) -> Game_Choice:
if input == "1":
Expand Down
4 changes: 4 additions & 0 deletions exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Custom exceptions"""

class SaveDataNotFound(Exception):
"""SaveData folder doesn't exist."""
9 changes: 9 additions & 0 deletions game_choice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Enum representing the choice of which game to configure"""
from enum import Enum


class Game_Choice(Enum):
"""Enum representing the choice of which game to configure"""

BL2 = "Borderlands 2"
TPS = "Borderlands The Pre-Sequel"
2 changes: 1 addition & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@


def main():
profile_path = get_profile_path()
profile_path = get_profile_path(CONFIG.user_os)
print("\nBacking up profile.bin to profile.bin.bak")
shutil.copyfile(profile_path, f"{profile_path}.bak")

Expand Down
7 changes: 4 additions & 3 deletions offset.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import hashlib
from pathlib import Path
import random

from config import CONFIG
from util import try_input


def calculate_offset(profile_path: str) -> int:
def calculate_offset(profile_path: str | Path) -> int:
while True:
complete_prompt = "Press enter once you are done. "
sens_1 = random.randrange(10, 100, 5)
Expand Down Expand Up @@ -39,13 +40,13 @@ def calculate_offset(profile_path: str) -> int:
print("\nUnable to calculate offset! Restarting.")


def get_current_sens(profile_path: str, offset: int) -> int:
def get_current_sens(profile_path: str | Path, offset: int) -> int:
with open(profile_path, "rb") as f:
profile = bytearray(f.read()[20:])
return profile[offset]


def rewrite_sens(profile_path: str, offset) -> int:
def rewrite_sens(profile_path: str | Path, offset) -> int:
sens = try_input(int, prompt="\nEnter your new desired sensitivity (1-255): ")
with open(profile_path, "rb") as f:
profile = bytearray(f.read()[20:])
Expand Down
82 changes: 42 additions & 40 deletions profiles.py
Original file line number Diff line number Diff line change
@@ -1,62 +1,64 @@
from ctypes import windll
import os
import string
from typing import List
from pathlib import Path

from config import CONFIG
from exceptions import SaveDataNotFound
from util import try_input
from user_os import UserOS


def input_to_game_path(input: str) -> str:
save_path = f"{input}\\SaveData\\"
if os.path.isdir(save_path):
CONFIG.set_data("path", save_path)
profile_path = f"{get_latest_directory(save_path)}\\profile.bin"
if os.path.isfile(profile_path):
return profile_path
save_path = Path(input) / "SaveData"
if save_path.is_dir():
CONFIG.set_data("path", str(save_path))
profile_path: Path = get_latest_directory(save_path) / "profile.bin"
if profile_path.is_file():
return str(profile_path)
raise Exception("profile.bin not found in given path.")


def get_drives() -> List[str]:
drives: List[str] = []
bitmask = windll.kernel32.GetLogicalDrives()
for letter in string.ascii_uppercase:
if bitmask & 1:
drives.append(letter)
bitmask >>= 1
return drives
def get_latest_directory(path: Path | None) -> Path:
"""Return the most recently modified folder within `path`.
Exceptions that may bubble up:
* FileNotFoundError
* TypeError - `path` is None, has no subdirectories, or doesn't exist"""
if path is None:
raise TypeError

def get_latest_directory(path: str) -> str:
return max([os.path.join(path, d) for d in os.listdir(path)], key=os.path.getmtime,)
subdirs = filter(lambda p: p.is_dir(), path.iterdir())
return max(subdirs, key=lambda p: p.stat().st_mtime)


def get_profile_path() -> str:
save_path = CONFIG.get_data("path")
game_choice = CONFIG.game_choice.value
def get_profile_path(user_os: UserOS) -> str | Path:
save_path_init: str | None = CONFIG.get_data("path")
if save_path_init is not None:
save_path = Path(save_path_init)
else:
try:
save_path = user_os.find_savedata()
except SaveDataNotFound as errpath:
save_path = None
print(
"It seems like your SaveData folder isn't in the usual location,",
errpath,
)
else:
CONFIG.set_data("path", str(save_path))

profile_path = ""
if not save_path:
path = "{0}:\\Users\\{1}\\Documents\\My Games\\{2}\\WillowGame\\SaveData\\"
user = os.environ["USERNAME"]
for drive in get_drives():
save_path = path.format(drive, user, game_choice)
if os.path.exists(save_path):
CONFIG.set_data("path", save_path)
break
# Sentinel value that almost certainly isn't valid a valid Path on a normal
# person's PC. This is extremely cursed and hacky. (Linear A Sign A661)
profile_path = Path("𐜳")
try:
profile_path = f"{get_latest_directory(save_path)}\\profile.bin"
except FileNotFoundError:
if not os.path.isfile(profile_path):
text = [
f"\nCould not find '{game_choice}\\WillowGame\\SaveData'.",
f"Please enter the full path to WillowGame, this can usually be found in 'Documents\\My Games\\{game_choice}'.",
f"Ex. C:\\Users\\CL4P-TP\\Documents\\My Games\\{game_choice}\\WillowGame",
]
latest_subdir = get_latest_directory(save_path)
profile_path = Path(latest_subdir) / "profile.bin"
except (TypeError, FileNotFoundError):
if not profile_path.is_file():
profile_path = try_input(
input_to_game_path,
text=text,
text=user_os.savedata_not_found,
error=f"Couldn't find WillowGame at that path. Please try entering your path again: ",
)

print(f"\nFound latest profile.bin at {profile_path}")
return profile_path
100 changes: 100 additions & 0 deletions user_os.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Platform-specific behaviors that must be implemented for each supported OS"""

from abc import ABC, abstractmethod
import os
from pathlib import Path

from exceptions import SaveDataNotFound
from game_choice import Game_Choice


class UserOS(ABC):
"""Base class for platform-specific things needed to find profile.bin"""

def __init__(self, game_choice: Game_Choice) -> None:
self.game: str = game_choice.value

@property
@abstractmethod
def savedata_not_found(self) -> list[str]:
"""Error message if the path to SaveData wasn't found"""
pass

@abstractmethod
def find_savedata(self) -> Path:
"""Return an absolute path to the chosen game's default SaveData folder
(where profile.bin is), or raise an exception if it wasn't found.
Exceptions:
* SaveDataNotFound - The SaveData folder doesn't exist at its default location.
"""
pass


class Linux(UserOS):
def __init__(self, game_choice: Game_Choice) -> None:
super().__init__(game_choice)
steam_ids = {
"Borderlands 2": "49520",
"Borderlands The Pre-Sequel": "261640",
}
self._steamid = steam_ids[self.game]
self._errmsg = [
f"\nCould not find '{self.game}/WillowGame/SaveData' for the Proton version of {self.game}.",
f"Please enter the full path to WillowGame, this can usually be found in",
f"'steamapps/compatdata/{self._steamid}/pfx/drive_c/users/steamuser/Documents/My Games/{self.game}'.",
f" ({'^' * len(self._steamid)} the Steam ID of {self.game})",
f"Ex. /home/CL4P-TP/.steam/steam/steamapps/...<ETC>.../{self.game}/WillowGame",
]

@property
def savedata_not_found(self) -> list[str]:
return self._errmsg

def find_savedata(self) -> Path:
save_path = (
Path.home()
/ f".steam/steam/steamapps/compatdata/{self._steamid}/pfx/drive_c/users/steamuser/Documents/My Games/{self.game}/WillowGame/SaveData/"
)
if not save_path.exists():
raise SaveDataNotFound(str(save_path))
return save_path


class Windows(UserOS):
def __init__(self, game_choice: Game_Choice) -> None:
super().__init__(game_choice)
self._errmsg = [
f"\nCould not find '{self.game}\\WillowGame\\SaveData'.",
f"Please enter the full path to WillowGame, this can usually be found in 'Documents\\My Games\\{self.game}'.",
f"Ex. C:\\Users\\CL4P-TP\\Documents\\My Games\\{self.game}\\WillowGame",
]

@staticmethod
def get_drives() -> list[str]:
from ctypes import windll # type: ignore
import string

drives: list[str] = []
bitmask = windll.kernel32.GetLogicalDrives()
for letter in string.ascii_uppercase:
if bitmask & 1:
drives.append(letter)
bitmask >>= 1
return drives

@property
def savedata_not_found(self) -> list[str]:
return self._errmsg

def find_savedata(self) -> Path:
user = os.environ["USERNAME"]
for drive in self.get_drives():
save_path = (
Path(f"{drive}:/Users")
/ f"{user}/Documents/My Games/{self.game}/WillowGame/SaveData"
)
if os.path.exists(save_path):
return save_path

raise SaveDataNotFound

0 comments on commit d6acce9

Please sign in to comment.