From 0dff50054298ab53dedda30aeac434993006d085 Mon Sep 17 00:00:00 2001 From: biqqles Date: Sat, 21 Mar 2020 21:31:20 +0000 Subject: [PATCH] Initial release --- .gitignore | 12 ++ README.md | 243 ++++++++++++++++++++++++++++++++++++ flair/__init__.py | 24 ++++ flair/__main__.py | 46 +++++++ flair/augment/__init__.py | 39 ++++++ flair/augment/cli.py | 85 +++++++++++++ flair/augment/clipboard.py | 59 +++++++++ flair/augment/screenshot.py | 38 ++++++ flair/docgen.py | 29 +++++ flair/hook/__init__.py | 10 ++ flair/hook/input.py | 176 ++++++++++++++++++++++++++ flair/hook/process.py | 129 +++++++++++++++++++ flair/hook/storage.py | 85 +++++++++++++ flair/hook/window.py | 52 ++++++++ flair/inspect/events.py | 49 ++++++++ flair/inspect/state.py | 235 ++++++++++++++++++++++++++++++++++ requirements.txt | 5 + setup.py | 30 +++++ 18 files changed, 1346 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 flair/__init__.py create mode 100644 flair/__main__.py create mode 100644 flair/augment/__init__.py create mode 100644 flair/augment/cli.py create mode 100644 flair/augment/clipboard.py create mode 100644 flair/augment/screenshot.py create mode 100644 flair/docgen.py create mode 100644 flair/hook/__init__.py create mode 100644 flair/hook/input.py create mode 100644 flair/hook/process.py create mode 100644 flair/hook/storage.py create mode 100644 flair/hook/window.py create mode 100644 flair/inspect/events.py create mode 100644 flair/inspect/state.py create mode 100644 requirements.txt create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..816d241 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +build/ +cache/ +dist/ +docs/ +*.egg-info/ +.idea/ +venv*/ + +*.backup +.directory +desktop.ini diff --git a/README.md b/README.md new file mode 100644 index 0000000..e702a38 --- /dev/null +++ b/README.md @@ -0,0 +1,243 @@ +# flair +**flair** (*FreeLancer Augmentation and Inspection at Runtime*) is a client-side hook for the 2003 space sim [*Freelancer*](https://en.wikipedia.org/wiki/Freelancer_%28video_game%29) which requires no changes to the game files and no code to be injected into the game's process. + +flair achieves this through a combination of hooking user input, reading process memory and window interaction using the Win32 API, and an understanding of Freelancer's static data provided by [flint](https://github.com/biqqles/flint). Through these means flair allows the game's state to be inspected in real-time. It also allows the game client to be augmented, for example adding clipboard access to the game's chat box or implementing a custom command line interface. + +### Installation +flair is on PyPI: + +``` +python -m pip install fl-flair +``` + +Alternatively you can install straight from this repository: + +```sh +python -m pip install https://github.com/biqqles/flair/archive/master.zip +``` + +Built wheels are also available under [Releases](https://github.com/biqqles/flair/releases), as is a changelog. flair requires Python 3.6 or higher. + +### Basic usage +flair must be run with administrator privileges. This is required for it to be able to hook keyboard input in Freelancer. + +flair includes a testing mode. To access it run `python -m flair `. In this mode flair will load all built-in augmentations, print all events, and begin polling and printing the state of the game to the terminal. More on all of those later. + +To use flair in your own programs, simply create an instance of `flair.FreelancerState`. This will begin polling the game (by default every 1 second). The polling frequency only affects when events are emitted - in most cases accesses to FreelancerState will cause the immediate state of the game to be read. + +Basic usage of flair relies on two main principles: + + - a state object, which allows access to the game's state in real time + - a simple events system inspired by Qt's signals and slots mechanism + +The API for the hook itself is also of course available for cases not covered by the builtin `FreelancerState`, like creating [augmentations](#augmentations). + +All these concepts are discussed in detail below. + +## API +### FreelancerState +As mentioned earlier, simply create an instance of `flair.FreelancerState` to get access to the game state. Its constructor takes one argument, the path to the game's folder. This object has the following methods and properties: + +|Methods |Type |Notes | +|:-------------------|:----------------|:----------------------------------------------------------------------------------------| +|`begin_polling(period)`| |Begin polling the game's state and emitting events. Called upon instantiation by default.| +|`stop_polling()` | |Stop polling the game's state and emitting events. | +|`refresh()` | |Cause state variables to refresh themselves | +|**Properties** |**Type** |**Notes** | +|**`running`** |`bool` |Whether an instance of the game is running | +|**`foreground`** |`bool` |Whether an instance of the game is in the foreground and accepting input | +|**`chat_box`** |`bool` |Whether the chat box is open | +|**`account`** |`str` |The "name" (hash) of the currently active account, taken from the registry | +|**`mouseover`** |`str` |See documentation in hook/process. | +|**`character_loaded`**|`bool` |Whether a character is currently loaded (i.e. the player is logged in), either in SP or MP| +|**`name`** |`Optional[str]` |The name of the currently active character if there is one, otherwise None | +|**`credits`** |`Optional[int]` |The number of credits the active character has if there is one, otherwise None | +|**`system`** |`Optional[str]` |The display name (e.g. "New London") of the system the active character is in if there is one, otherwise None| +|**`base`** |`Optional[str]` |The display name (e.g. "The Ring") of the base the active character last docked at if there is one, otherwise None| +|**`pos`** |`Optional[PosVector]`|The position vector of the active character if there is one, otherwise None | +|**`docked`** |`Optional[bool]` |Whether the active character is presently docked at a base if there is one, otherwise None| + +Source: [flair/inspect/state.py](flair/inspect/state.py) + +### Events +Events are used by "connecting" them to functions (or vice-versa). flair automatically "emits" these events when necessary. For example `flair.events.message_sent.connect(lambda message: print(message))` causes that lambda to be called every time flair emits the `message_sent` signal, thereby printing the contents of the message to the terminal. + +|Event |Emitted when | Parameter(s) | +|:---------------------------|:----------------------------------|:--------------------------------------------------------------| +|**`character_changed`** |New character loaded |`name`=new character name | +|**`account_changed`** |Active (registry) account changed |`account`=new account code | +|**`system_changed`** |New system entered |`system`=New system display name | +|**`docked`** |Docked base (respawn point) changed|`base`=new base display name | +|**`undocked`** |Undocked from base |N/A | +|**`credits_changed`** |Credit balance changed |`balance`=new credit balance | +|**`message_sent`** |New message sent by player |`message`=message text | +|**`chat_box_opened`** |Chat box opened |`message`=message text | +|**`chat_box_closed`** |Chat box closed |`message_sent`=whether message sent | +|**`freelancer_started`** |Freelancer process launched |N/A | +|**`freelancer_stopped`** |Freelancer process closed |N/A | +|**`switched_to_foreground`**|Freelancer switched to foreground |N/A | +|**`switched_to_background`**|Freelancer switched to background |N/A | + +Source: [flair/inspect/events.py](flair/inspect/events.py) + +### Hook +[`flair/hook`](flair/hook) contains the lower-level code for hooking into the game. You will likely only need to use this directly if you wish to go beyond simply using `FreelancerState` and the events system. It is separated into the following modules: + +#### Input +Source: [flair/hook/input](flair/hook/input) + +##### `bind_hotkey(combination, function)` +Adds a hotkey which is only active when Freelancer is in the foreground. + +##### `unbind_hotkey(combination, function)` +Removes a hotkey that has been bound in the Freelancer window. + +##### `queue_display_text(text: str)` +Queue text to be displayed to the user. If Freelancer is in the foreground and the chat box is closed, +this will be shown immediately. Otherwise, the text will be shown as soon as both these conditions are true. + +##### `send_message(message: str, private=True)` +Inserts `message` into the chat box and sends it. If `private` is true, send to the Console. + +##### `inject_keys(key: str, after_delay=0.0)` +Inject a key combination into the Freelancer window. + +##### `inject_text(text: str)` +Inject text into the chat box. + +##### `initialise_hotkey_hooks()` +Initialise hotkey hooks for Freelancer. Should be run when, and only when, the Freelancer window is brought into +the foreground. + +##### `terminate_hotkey_hooks()` +Release hotkey hooks for Freelancer. Should be run when, and only when, the Freelancer window is put into the +background. + +##### `on_chat_box_opened()` +Handle the user opening the chat box. Emits the `chat_box_opened` signal. + +##### `on_chat_box_closed(cancelled=False)` +Handle the user closing the chat box. Emits the `chat_box_closed` signal. + +##### `collect_chat_box_events(event)` +Handle a keyboard event while the chat box is open. +# Todo: handle arrow keys, copy and paste + +##### `get_chat_box_contents()` +Return (our best guess at) the current contents of the chat box. If it is closed, returns a blank string. + +##### `get_chat_box_open_hotkey()` +Return the hotkey configured to open the chat box. + +#### Process +Source: [flair/hook/process](flair/hook/process)##### `get_process() -> ` +Return a handle to Freelancer's process. + +##### `read_memory(process, address, datatype, buffer_size=128)` +Reads Freelancer's process memory. + +Just as with string resources, strings are stored as UTF-16 meaning that the end of a string is marked by two +consecutive null bytes. However, other bytes will be present in the buffer after these two nulls since it is of +arbitrary size, and these confuse Python's builtin .decode and result in UnicodeDecodeError. So we can't use it. + +##### `get_value(process, key, size=None)` +Read a value from memory. `key` refers to the key of an address in `READ_ADDRESSES` + +##### `get_string(process, key, length)` +Read a UTF-16 string from memory. + +##### `get_name(process) -> str` +Read the name of the active character from memory. + +##### `get_credits(process) -> int` +Read the credit balance of the active character from memory. + +##### `get_position(process) -> Tuple[float, float, float]` +Read the position of the active character from memory. + +##### `get_mouseover(process) -> str` +This is a really interesting address. It seems to store random, unconnected pieces of text that have been +recently displayed or interacted with in the game. These range from console outputs to the names of bases +and planets immediately upon jumping in or docking, to the prices of commodities in the trader screen, to +mission "popups" messages, to the name of some solars and NPCs that are moused over while in space. +With some imagination this can probably be put to some use... + +##### `get_rollover(process) -> str` +Similar to mouseover, but usually contains tooltip text. + +##### `get_last_message(process) -> str` +Read the last message sent by the player from memory + +##### `get_chat_box_state(process) -> bool` +Read the state of the chat box from memory. + +##### `get_character_loaded(process) -> bool` +Read whether a character is loaded (whether in SP or MP). + +##### `get_docked(process) -> bool` +Read whether the active character is docked. + +#### Window +Source: [flair/hook/window](flair/hook/window)##### `get_hwnd() -> int` +Returns a non-zero window handle to Freelancer if a window exists, otherwise, returns zero. + +##### `is_present() -> bool` +Reports whether Freelancer is running. + +##### `is_foreground() -> bool` +Reports whether Freelancer is in the foreground and accepting input. + +##### `get_screen_coordinates()` +Return the screen coordinates for the contents ("client"; excludes window decorations) of a Freelancer window. + +##### `make_borderless()` +Remove the borders and titlebar from the game running in windowed mode. +Todo: Windowed mode seems to cut off the bottom of the game. This is something that will need worked around. + +#### Storage +Source: [flair/hook/storage](flair/hook/storage)##### `get_active_account_name() -> str` +Returns the currently active account's code ("name") from the registry. +Note that Freelancer reads the account from the registry at the time of server connect. + +##### `virtual_key_to_name(vk) -> str` +Get the name of a key from its VK (virtual key) code. + +##### `get_user_keymap() -> Dict[str, str]` +Get Freelancer's current key map as defined in UserKeyMap.ini, in a format understood by the `keyboard` +module. + +##### `tail_chat_log()` +(Discovery only) +Get the last message recorded in DSAce.log + +##### `get_launcher_accounts() -> Dict[str, Tuple[str, str]]` +(Discovery only) +Parse launcheraccounts.xml to a dictionary of tuples of the form {code: (name, description)}. + +### Augmentations +"Augmentations" are modules that augment the game client. flair includes several such built-in example modules. + +To create an augmentation, subclass `flair.augment.Augmentation`. Simply override the methods `load()` and `unload()`. These are run when augmentations are "loaded" into the game client and "unloaded" respectively. Connect up the events you need to use and add any other setup here. + +Source: [flair/augment](flair/augment) + +#### Clipboard +Adds clipboard access to the chat box. Use Ctrl+Shift+C to copy the contents of the chat box and Ctrl+Shift+V to paste text to it. + +#### CLI +Adds a basic command-line interface to the game. +If you are on a vanilla server you will need to type commands into the console (press ↑ in the chat box). This is not necessary on a server running FLHook as it will recognise them as commands and not send them to other players. Output is returned in chat, again through the console. + +- `date`: print the local date and time +- `sector`: print the current sector and system +- `eval`: evaluate the given expression with Python +- `quit`: quit the game +- `help`: show this help message + +#### Screenshot +Adds proper screenshot functionality to the game, similar to that found in games like *World of Warcraft*. Screenshots are automatically named with a timestamp and the system name and saved to `My Games/Freelancer/Screenshots` with the character name as the directory. Screenshots are taken using Ctrl+PrintScreen. + +### To do +- Reimplementing Wizou's multiplayer code +- Increasing the robustness of determining the chat box contents - currently it does not handle arrow keys +- Getting system and base is currently pretty hacky, and it often requires a dock and undock to set both after loading a character diff --git a/flair/__init__.py b/flair/__init__.py new file mode 100644 index 0000000..fad4334 --- /dev/null +++ b/flair/__init__.py @@ -0,0 +1,24 @@ +""" + Copyright (C) 2016, 2017, 2020 biqqles. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" +__version__ = 0.1 +try: + from ctypes import windll + IS_WIN = True + IS_ADMIN = bool(windll.advpack.IsNTAdmin(0, None)) +except ImportError: + IS_WIN = False + IS_ADMIN = False +else: + del windll + +if not IS_ADMIN: + raise ImportError('flair must be run as administrator') + +from . import hook, augment, inspect +from .inspect.state import FreelancerState +from .inspect import events diff --git a/flair/__main__.py b/flair/__main__.py new file mode 100644 index 0000000..6870ba6 --- /dev/null +++ b/flair/__main__.py @@ -0,0 +1,46 @@ +""" + Copyright (C) 2016, 2017, 2020 biqqles. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" +import argparse +import os + +from . import events, augment +from . import FreelancerState + + +if __name__ == '__main__': + # parse command line arguments + parser = argparse.ArgumentParser(prog='flair', description='flair, a novel client-side hook for Freelancer') + parser.add_argument('freelancer_dir', help='Path to a working Freelancer install directory') + arguments = parser.parse_args() + + # enable ANSI colour codes on Windows + os.system('color') + + def print_event(*args): + """Print an event to terminal with emphasis (using ANSI colour codes). How this displays exactly varies between + terminals.""" + print('\033[1m' + ' '.join(map(str, args)) + '\033[0m') + + events.message_sent.connect(lambda message: print_event('Message sent:', message)) + events.freelancer_started.connect(lambda: print_event('Freelancer started')) + events.freelancer_stopped.connect(lambda: print_event('Freelancer stopped')) + events.system_changed.connect(lambda system: print_event('System entered:', system)) + events.docked.connect(lambda base: print_event('Docked at:', base)) + events.undocked.connect(lambda: print_event('Undocked from base')) + events.credits_changed.connect(lambda balance: print_event('Credit balance changed:', balance)) + events.character_changed.connect(lambda name: print_event('Character loaded:', name)) + events.account_changed.connect(lambda name: print_event('Account loaded:', name)) + events.chat_box_opened.connect(lambda: print_event('Chat box opened')) + events.chat_box_closed.connect( + lambda message_sent: print_event('Chat box closed, message', f'{"" if message_sent else "un"}sent')) + events.switched_to_foreground.connect(lambda: print_event('Freelancer switched to foreground')) + events.switched_to_background.connect(lambda: print_event('Freelancer switched to background')) + + game_state = FreelancerState(arguments.freelancer_dir) + game_state.begin_polling() + augmentations = augment.Augmentation.load_all(game_state) diff --git a/flair/augment/__init__.py b/flair/augment/__init__.py new file mode 100644 index 0000000..97d4075 --- /dev/null +++ b/flair/augment/__init__.py @@ -0,0 +1,39 @@ +""" + Copyright (C) 2016, 2017, 2020 biqqles. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" +from abc import ABC, abstractmethod +from typing import List + +from ..inspect.state import FreelancerState + + +class Augmentation(ABC): + """The base (abstract) class for a game client augmentation.""" + def __init__(self, state: FreelancerState): + self._state = state + + @abstractmethod + def load(self): + """Run when an augmentation is "loaded" into the game client. Connect up the events you need to use and add any + other setup here.""" + pass + + @abstractmethod + def unload(self): + """Run when an augmentation is "unloaded" from the game client. Disconnect any events used here.""" + pass + + @classmethod + def load_all(cls, state: FreelancerState) -> List['Augmentation']: + """Instantiate and load all subclasses of this class. Returns a list containing references to these instances. + Keep a reference to this to avoid them being garbage collected.""" + # ensure built-in augmentations are interpreted + from . import cli, clipboard, screenshot + instances = [s(state) for s in cls.__subclasses__()] + for a in instances: + a.load() + return instances diff --git a/flair/augment/cli.py b/flair/augment/cli.py new file mode 100644 index 0000000..92b079b --- /dev/null +++ b/flair/augment/cli.py @@ -0,0 +1,85 @@ +import datetime + +import flint +from ..inspect.state import FreelancerState + +from . import Augmentation +from ..inspect.events import message_sent, character_changed +from ..hook import input +from .. import __version__ + + +class CLI(Augmentation): + """Adds a basic command-line interface.""" + INVOCATION = '..' + + class Command: + """A command available in the in-game shell""" + state: FreelancerState + + def __init__(self, state): + self.state = state + + def __call__(self, *args, **kwargs): + pass + + class Date(Command): + """Print the local date and time""" + + def __call__(self): + time = datetime.datetime.now() + input.queue_display_text(time.strftime("%c")) # formatted to locale + + class Sector(Command): + """Print the current sector and system""" + def __call__(self): + if not self.state.system: + input.queue_display_text(f'Err: unknown location') + else: + systems = {s.name(): s for s in flint.get_systems()} + system_scale = systems[self.state.system].navmapscale + sector = flint.maps.pos_to_sector(self.state.pos, system_scale) + input.queue_display_text(f'You are in sector {sector}, {self.state.system}') + + class Eval(Command): + """Evaluate the given expression with Python""" + def __call__(self, *expression): + if expression: + input.queue_display_text(f"= {str(eval(' '.join(expression)))}") + + class Quit(Command): + """Quit the game""" + def __call__(self): + input.inject_keys('alt+f4') + input.inject_keys('enter', after_delay=0.05) + + class Help(Command): + """Show this help message""" + def __call__(self): + input.queue_display_text(f"flair version {__version__}") + for c in self.__class__.__bases__[0].__subclasses__(): + input.queue_display_text(f'{CLI.INVOCATION}{c.__name__}: {c.__doc__}'.lower()) + + def load(self): + character_changed.connect(self.show_welcome_message) + message_sent.connect(self.parse_message) + + def unload(self): + character_changed.disconnect(self.show_welcome_message) + message_sent.disconnect(self.parse_message) + + @staticmethod + def show_welcome_message(character_name: str): + input.queue_display_text(f'Welcome to Sirius, {character_name}. You\'re using flair {__version__}. ' + 'Type ..help to list a few commands.') + + def parse_message(self, message: str): + """Parse and interpret a message.""" + preamble, invocation, command = message.strip().partition(self.INVOCATION) + if invocation and not preamble: + command_name, *args = [t for t in command.split()] + commands = {c.__name__.lower(): c for c in self.Command.__subclasses__()} + if command_name in commands: + commands[command_name](self._state)(*args) + else: + input.queue_display_text('Err: command not found') diff --git a/flair/augment/clipboard.py b/flair/augment/clipboard.py new file mode 100644 index 0000000..fcd8f6f --- /dev/null +++ b/flair/augment/clipboard.py @@ -0,0 +1,59 @@ +""" + Copyright (C) 2016, 2017, 2020 biqqles. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" +import keyboard +import win32clipboard + +from . import Augmentation +from ..hook import input, process +from ..inspect.events import chat_box_opened, chat_box_closed + + +class Clipboard(Augmentation): + """Adds clipboard functionality to the chat box.""" + HOTKEY_PASTE = 'ctrl+shift+v' + HOTKEY_COPY = 'ctrl+shift+c' + + def load(self): + chat_box_opened.connect(self.hook_copy) + chat_box_opened.connect(self.hook_paste) + chat_box_closed.disconnect(self.hook_copy) + chat_box_closed.disconnect(self.hook_paste) + + def unload(self): + chat_box_opened.disconnect(self.hook_copy) + chat_box_opened.disconnect(self.hook_paste) + + @classmethod + def hook_copy(cls): + keyboard.add_hotkey(cls.HOTKEY_COPY, cls.copy_from_chat_box) + + @classmethod + def hook_paste(cls): + keyboard.add_hotkey(cls.HOTKEY_PASTE, cls.paste_to_chat_box) + + @staticmethod + def copy_from_chat_box(): + assert process.get_chat_box_state(process.get_process()) + text = input.get_chat_box_contents() + if text: # if there's nothing in the chat box don't just wipe out what's currently in the clipboard + win32clipboard.OpenClipboard() + win32clipboard.EmptyClipboard() + win32clipboard.SetClipboardText(text, win32clipboard.CF_UNICODETEXT) + win32clipboard.CloseClipboard() + + @staticmethod + def paste_to_chat_box(): + assert process.get_chat_box_state(process.get_process()) + win32clipboard.OpenClipboard() + try: + clipboard = win32clipboard.GetClipboardData(win32clipboard.CF_UNICODETEXT) + input.inject_text(clipboard.replace('\n', '')) + except TypeError: # raised if something that isn't text is in the clipboard + pass + finally: + win32clipboard.CloseClipboard() diff --git a/flair/augment/screenshot.py b/flair/augment/screenshot.py new file mode 100644 index 0000000..98d3331 --- /dev/null +++ b/flair/augment/screenshot.py @@ -0,0 +1,38 @@ +""" + Copyright (C) 2016, 2017, 2020 biqqles. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" +from datetime import datetime + +from PIL import ImageGrab + +from ..hook import window, input +from . import Augmentation + +import os + + +class Screenshot(Augmentation): + """Adds proper screenshot functionality to the game.""" + HOTKEY = 'ctrl+prtscn' + screenshots_root_dir = os.path.expanduser('~/Documents/My Games/Freelancer/Screenshots') + + def load(self): + os.makedirs(self.screenshots_root_dir, exist_ok=True) + input.bind_hotkey(self.HOTKEY, self.take_screenshot) + + def unload(self): + input.unbind_hotkey(self.HOTKEY, self.take_screenshot) + + def take_screenshot(self): + """Take and save am auto-named screenshot""" + character_name = self._state.name + system_name = self._state.system + date = datetime.now().strftime('%y-%m-%d %H.%M.%S') # in practice only one screenshot can be taken per second + directory_path = os.path.join(self.screenshots_root_dir, character_name) + file_path = os.path.join(directory_path, f'{date} {system_name}.png') + os.makedirs(directory_path, exist_ok=True) + ImageGrab.grab(window.get_screen_coordinates()).save(file_path, 'PNG') diff --git a/flair/docgen.py b/flair/docgen.py new file mode 100644 index 0000000..d43dc9a --- /dev/null +++ b/flair/docgen.py @@ -0,0 +1,29 @@ +import types + +from flair.hook import input, process, window, storage +import inspect + +output = '' + +# for module in input, process, window, storage: +# output += module.__name__ + '\n
\n' +# print(list(getattr(module, t) for t in dir(module))) +# moduleattrs = sorted(dir(module), key=lambda t: inspect.findsource(getattr(module, t))[1] if isinstance(getattr(module, t), types.FunctionType) else float('inf')) +# for function in [getattr(module, a) for a in moduleattrs if isinstance(getattr(module, a), types.FunctionType) and getattr(module, a).__module__ == module.__name__]: +# output += '
' + function.__name__ + str(inspect.signature(function)) + '
\n' +# output += '
' + str(inspect.getdoc(function)) + '
\n\n' +# output += '
\n\n\n' +# print(output) + +for module in input, process, window, storage: + output += '#### ' + module.__name__.split('.')[-1].capitalize() + '\n' + path_to_module = module.__name__.replace('.', '/') + output += f'Source: [{path_to_module}]({path_to_module})' + moduleattrs = sorted(dir(module), key=lambda t: inspect.findsource(getattr(module, t))[1] if isinstance(getattr(module, t), types.FunctionType) else float('inf')) + for function in [getattr(module, a) for a in moduleattrs if isinstance(getattr(module, a), types.FunctionType) and getattr(module, a).__module__ == module.__name__]: + if function.__name__.startswith('_'): + continue + output += '##### `' + function.__name__ + str(inspect.signature(function)) + '`\n' + output += str(inspect.getdoc(function)) + '\n\n' +output += '\n\n\n' +print(output) diff --git a/flair/hook/__init__.py b/flair/hook/__init__.py new file mode 100644 index 0000000..b889f7d --- /dev/null +++ b/flair/hook/__init__.py @@ -0,0 +1,10 @@ +""" + Copyright (C) 2016, 2017, 2020 biqqles. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + + This file ensures all hook components are initialised. +""" +from . import input, process, storage, window diff --git a/flair/hook/input.py b/flair/hook/input.py new file mode 100644 index 0000000..8b0ae38 --- /dev/null +++ b/flair/hook/input.py @@ -0,0 +1,176 @@ +""" + Copyright (C) 2016, 2017, 2020 biqqles. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" +from types import FunctionType + +from .. import IS_ADMIN + +if not IS_ADMIN: + raise ImportError('flair must be run as administrator to be able to hook keyboard input in Freelancer') + +import time +from typing import List, Tuple + +import keyboard as keyboard + +from . import process, window, storage +from ..inspect.events import message_sent, chat_box_closed, chat_box_opened # emits +from ..inspect.events import switched_to_background, switched_to_foreground # utilises + +INDENT = ' ' * 4 +ECHO_PREAMBLE = '|=| ' +KEY_SEND_MESSAGE = 'enter' # unlike the hotkey to open the chat box, these keys are hardcoded +KEY_CLOSE_CHAT_BOX = 'esc' +KEY_CHANGE_CHAT_CHANNEL = 'up' + +chat_box_events: List[keyboard.KeyboardEvent] = [] +message_queue: List[str] = [] # messages to be shown to player +hotkeys: List[Tuple[str, FunctionType]] = [] + + +def bind_hotkey(combination, function): + """Adds a hotkey which is only active when Freelancer is in the foreground.""" + assert callable(function) + hotkeys.append((combination, function)) + + +def unbind_hotkey(combination, function): + """Removes a hotkey that has been bound in the Freelancer window.""" + hotkeys.remove((combination, function)) + + +def queue_display_text(text: str): + """Queue text to be displayed to the user. If Freelancer is in the foreground and the chat box is closed, + this will be shown immediately. Otherwise, the text will be shown as soon as both these conditions are true.""" + time.sleep(.1) # wait for chat box state to propagate + if window.is_foreground() and not process.get_chat_box_state(process.get_process()): # show immediately + send_message(text) + else: + message_queue.append(text) + + +def send_message(message: str, private=True): + """Inserts `message` into the chat box and sends it. If `private` is true, send to the Console.""" + assert window.is_foreground(), not process.get_chat_box_state(process.get_process()) + text = str(message).encode('ascii', errors='ignore').decode() # strip out all non-ascii characters + inject_keys(get_chat_box_open_hotkey()) # open chat box + if private: + inject_keys(KEY_CHANGE_CHAT_CHANNEL, after_delay=.001) # switch to Console (private to player) + text = ECHO_PREAMBLE + text + keyboard.write(text) + keyboard.send(KEY_SEND_MESSAGE) + + +def inject_keys(key: str, after_delay=0.0): + """Inject a key combination into the Freelancer window.""" + assert window.is_foreground() + time.sleep(after_delay) + keyboard.send(key) + + +def inject_text(text: str): + """Inject text into the chat box.""" + assert process.get_chat_box_state(process.get_process()) + keyboard.write(text) + + +def initialise_hotkey_hooks(): + """Initialise hotkey hooks for Freelancer. Should be run when, and only when, the Freelancer window is brought into + the foreground.""" + assert IS_ADMIN, window.is_present() + # the user_chat hotkey always opens the chat box, no matter which other dialogues are up + box_initially_open = process.get_chat_box_state(process.get_process()) + if box_initially_open: + on_chat_box_opened() + else: + on_chat_box_closed() + + # hook registered hotkeys + for k, f in hotkeys: + keyboard.add_hotkey(k, f) + + +def terminate_hotkey_hooks(): + """Release hotkey hooks for Freelancer. Should be run when, and only when, the Freelancer window is put into the + background.""" + try: + keyboard.remove_all_hotkeys() + except AttributeError: + pass # a bug in keyboard (todo: submit PR) + finally: + keyboard.unhook_all() + + +def on_chat_box_opened(): + """Handle the user opening the chat box. Emits the `chat_box_opened` signal.""" + if window.is_foreground(): + handle = process.get_process() + if _wait_until(lambda: process.get_chat_box_state(handle)): + terminate_hotkey_hooks() + # begin capturing keystrokes + keyboard.hook(collect_chat_box_events) + # add hooks for close + keyboard.add_hotkey(KEY_SEND_MESSAGE, on_chat_box_closed, args=[False]) + keyboard.add_hotkey(KEY_CLOSE_CHAT_BOX, on_chat_box_closed, args=[True]) + # emit signal + chat_box_opened.emit() + else: + initialise_hotkey_hooks() + + +def on_chat_box_closed(cancelled=False): + """Handle the user closing the chat box. Emits the `chat_box_closed` signal.""" + if window.is_foreground(): + handle = process.get_process() + if _wait_until(lambda: not process.get_chat_box_state(handle)): + # print queued messages + if process.get_character_loaded(process.get_process()): + while message_queue: + send_message(message_queue.pop(0)) + # emit signals + contents = get_chat_box_contents() + sent = not cancelled and contents + chat_box_closed.emit(message_sent=sent) + if sent: + message_sent.emit(message=contents) # todo: may want to compare to process.get_last_message + terminate_hotkey_hooks() + # add hook for open + keyboard.add_hotkey(get_chat_box_open_hotkey(), on_chat_box_opened) + # clear events + chat_box_events.clear() + else: + initialise_hotkey_hooks() + + +def collect_chat_box_events(event): + """Handle a keyboard event while the chat box is open. + # Todo: handle arrow keys, copy and paste""" + if window.is_foreground(): + chat_box_events.append(event) + + +def get_chat_box_contents(): + """Return (our best guess at) the current contents of the chat box. If it is closed, returns a blank string.""" + return ''.join(keyboard.get_typed_strings(chat_box_events, allow_backspace=True)) + + +def get_chat_box_open_hotkey(): + """Return the hotkey configured to open the chat box.""" + return storage.get_user_keymap()['user_chat'] + + +def _wait_until(condition, timeout=0.1): + """Block until condition returns True, or the timeout (in seconds) is reached. Return True if condition became + true before the timeout, otherwise False.""" + start = time.time() + while not condition() and (time.time() - start) < timeout: + continue + return condition() + + +switched_to_foreground.connect(initialise_hotkey_hooks) +switched_to_background.connect(terminate_hotkey_hooks) diff --git a/flair/hook/process.py b/flair/hook/process.py new file mode 100644 index 0000000..047c674 --- /dev/null +++ b/flair/hook/process.py @@ -0,0 +1,129 @@ +""" + Copyright (C) 2016, 2017, 2020 biqqles. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" +import win32con + +from .. import IS_WIN + +from typing import Tuple +from pywintypes import HANDLE + +from ctypes import * +if IS_WIN: + import win32api + import win32process + +from .window import get_hwnd + + +PROCESS_VM_READ = 0x10 # + +# useful addresses to read from. These are all static, obviously. In the future I may look for static pointers to +# dynamic addresses. Offsets correct for v1.1 executable. +READ_ADDRESSES = { + 'last_message': (0x66D4F2, str), + 'mouseover': (0x66DC60, str), + 'rollover': (0x66FC60, str), # seems to show rollover help, solar rollovers and information dialogue text + 'name': (0x6732F0, str), + 'credits': (0x673364, c_uint), + 'pos_x': (0x6732E4, c_float), + 'pos_y': (0x6732E8, c_float), + 'pos_z': (0x6732EC, c_float), + 'enter_dialogue': (0x67C3AC, c_uint32), # seems to be nonzero for dialogues that hook enter + 'focus_dialogue': (0x667D54, c_uint32), # true for dialogues which darken the screen - pause menu and exit game + 'logged_in': (0x67E9D4, c_uint32), # whether player is logged in (whether in SP or to a MP server) + 'singleplayer': (0x67334C, c_uint32), # whether player is loaded into SP + 'in_space': (0x673560, c_uint32), # whether the player is in space (not docked) (possibly also 0x673530) +} + + +def get_process() -> HANDLE: + """Return a handle to Freelancer's process.""" + hwnd = get_hwnd() + pid = win32process.GetWindowThreadProcessId(hwnd)[1] + process = win32api.OpenProcess(win32con.PROCESS_VM_READ, 0, pid) + return process + + +def read_memory(process, address, datatype, buffer_size=128): + """Reads Freelancer's process memory. + + Just as with string resources, strings are stored as UTF-16 meaning that the end of a string is marked by two + consecutive null bytes. However, other bytes will be present in the buffer after these two nulls since it is of + arbitrary size, and these confuse Python's builtin .decode and result in UnicodeDecodeError. So we can't use it. + """ + buffer = create_string_buffer(buffer_size) + value = datatype() + if windll.kernel32.ReadProcessMemory(process.handle, address, buffer, len(buffer), 0): + if datatype == str: + value = ''.join(map(chr, buffer.raw[:buffer.raw.index(b'\0\0'):2])) + else: + memmove(byref(value), buffer, sizeof(value)) + value = value.value # C type -> Python type, effectively + return value + + +def get_value(process, key, size=None): + """Read a value from memory. `key` refers to the key of an address in `READ_ADDRESSES`""" + address, datatype = READ_ADDRESSES[key] + return read_memory(process, address, datatype, buffer_size=size or (sizeof(datatype) * 8)) + + +def get_string(process, key, length): + """Read a UTF-16 string from memory.""" + return get_value(process, key, (length * 2) + 2) + + +def get_name(process) -> str: + """Read the name of the active character from memory.""" + return get_string(process, 'name', 23) + + +def get_credits(process) -> int: + """Read the credit balance of the active character from memory.""" + return get_value(process, 'credits') + + +def get_position(process) -> Tuple[float, float, float]: + """Read the position of the active character from memory.""" + return get_value(process, 'pos_x'), get_value(process, 'pos_y'), get_value(process, 'pos_z') + + +def get_mouseover(process) -> str: + """This is a really interesting address. It seems to store random, unconnected pieces of text that have been + recently displayed or interacted with in the game. These range from console outputs to the names of bases + and planets immediately upon jumping in or docking, to the prices of commodities in the trader screen, to + mission "popups" messages, to the name of some solars and NPCs that are moused over while in space. + With some imagination this can probably be put to some use...""" + return get_string(process, 'mouseover', 128) + + +def get_rollover(process) -> str: + """Similar to mouseover, but usually contains tooltip text.""" + return get_string(process, 'rollover', 128) + + +def get_last_message(process) -> str: + """Read the last message sent by the player from memory""" + return get_string(process, 'last_message', 127) + + +def get_chat_box_state(process) -> bool: + """Read the state of the chat box from memory.""" + dialogue_hooking_enter = get_value(process, 'enter_dialogue') + dialogue_focused = get_value(process, 'focus_dialogue') + return bool(dialogue_hooking_enter and not dialogue_focused) + + +def get_character_loaded(process) -> bool: + """Read whether a character is loaded (whether in SP or MP).""" + return bool(get_value(process, 'logged_in')) + + +def get_docked(process) -> bool: + """Read whether the active character is docked.""" + return not bool(get_value(process, 'in_space')) diff --git a/flair/hook/storage.py b/flair/hook/storage.py new file mode 100644 index 0000000..6db7a11 --- /dev/null +++ b/flair/hook/storage.py @@ -0,0 +1,85 @@ +""" + Copyright (C) 2016, 2017, 2020 biqqles. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" +import os +from typing import Dict, Tuple +import xml.etree.ElementTree + +from flint.formats import ini + +from .. import IS_WIN + +if IS_WIN: + import ctypes + import win32api + import winreg + +REGISTRY_DIR = r'Software\Microsoft\Microsoft Games\Freelancer\1.0' +USER_KEY_MAP = os.path.expanduser(r'~\Documents\My Games\Freelancer\UserKeyMap.ini') +DSY_LAUNCHER_ACCOUNTS = os.path.expanduser(r'~\Documents\My Games\Discovery\launcheraccounts.xml') +DSY_DSACE = os.path.expanduser(r'~\Documents\My Games\Freelancer\DSAce.log') +CHAT_MESSAGE_MAX_LENGTH = 140 + + +def get_active_account_name() -> str: + """Returns the currently active account's code ("name") from the registry. + Note that Freelancer reads the account from the registry at the time of server connect. + """ + assert IS_WIN + handle = winreg.CreateKey(winreg.HKEY_CURRENT_USER, REGISTRY_DIR) + return winreg.QueryValueEx(handle, 'MPAccountName')[0] + + +def virtual_key_to_name(vk) -> str: + """Get the name of a key from its VK (virtual key) code.""" + assert IS_WIN + scan_code = win32api.MapVirtualKey(vk, 0) + # pywin32 doesn't include GetKeyNameTextW so we need to use windll + name_buffer = ctypes.create_unicode_buffer(32) + ctypes.windll.user32.GetKeyNameTextW((scan_code << 16 | 0 << 24 | 1 << 25), name_buffer, len(name_buffer)) + if not name_buffer.value: + raise ValueError(f'Invalid virtual key: {vk}') + return name_buffer.value.lower() + + +def get_user_keymap() -> Dict[str, str]: + """Get Freelancer's current key map as defined in UserKeyMap.ini, in a format understood by the `keyboard` + module.""" + assert IS_WIN + key_map = ini.parse(USER_KEY_MAP, 'keycmd', fold_values=False) + + result = {} # nicknames to keyboard "hotkeys" + + for keycmd in key_map: + # each "key" in the INI consists of exactly one VK code, and up to one modifier represented as a name + nickname, key_combo = keycmd['nickname'][0], keycmd['key'][0] # todo: only gets first to simply things + vk, *modifier = str(key_combo).split(',') + try: + name = virtual_key_to_name(int(vk)) + except ValueError: + continue + + formatted = name + '+' + modifier[0] if modifier else name # form into a string keyboard can understand + result[nickname] = formatted + return result + + +def tail_chat_log(): + """(Discovery only) + Get the last message recorded in DSAce.log""" + file_length = os.stat(DSY_DSACE).st_size + f = open(DSY_DSACE) + f.seek(file_length - CHAT_MESSAGE_MAX_LENGTH) # can't do nonzero end-relative seeks so do this instead + tail = f.read() + return tail.splitlines()[-1] + + +def get_launcher_accounts() -> Dict[str, Tuple[str, str]]: + """(Discovery only) + Parse launcheraccounts.xml to a dictionary of tuples of the form {code: (name, description)}.""" + root = xml.etree.ElementTree.parse(DSY_LAUNCHER_ACCOUNTS).getroot() + return {a.get('code'): (a.text, a.get('description')) for a in root.findall('account')} diff --git a/flair/hook/window.py b/flair/hook/window.py new file mode 100644 index 0000000..d11ea2a --- /dev/null +++ b/flair/hook/window.py @@ -0,0 +1,52 @@ +""" + Copyright (C) 2016, 2017, 2020 biqqles. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" +from .. import IS_WIN + +if IS_WIN: + import win32con + import win32gui + +WINDOW_TITLE = 'Freelancer' + + +def get_hwnd() -> int: + """Returns a non-zero window handle to Freelancer if a window exists, otherwise, returns zero.""" + return win32gui.FindWindow(WINDOW_TITLE, WINDOW_TITLE) + + +def is_present() -> bool: + """Reports whether Freelancer is running.""" + return bool(get_hwnd()) if IS_WIN else False + + +def is_foreground() -> bool: + """Reports whether Freelancer is in the foreground and accepting input.""" + return (win32gui.GetForegroundWindow() == get_hwnd()) if IS_WIN else False + + +def get_screen_coordinates(): + """Return the screen coordinates for the contents ("client"; excludes window decorations) of a Freelancer window.""" + hwnd = get_hwnd() + left_x, top_y, right_x, bottom_y = win32gui.GetClientRect(hwnd) + left_x, top_y = win32gui.ClientToScreen(hwnd, (left_x, top_y)) # convert "client" coordinates to screen coordinates + right_x, bottom_y = win32gui.ClientToScreen(hwnd, (right_x, bottom_y)) + return left_x, top_y, right_x, bottom_y + + +def make_borderless(): + """Remove the borders and titlebar from the game running in windowed mode. + Todo: Windowed mode seems to cut off the bottom of the game. This is something that will need worked around.""" + hwnd = get_hwnd() + style = win32gui.GetWindowLong(hwnd, win32con.GWL_STYLE) + style &= ~win32con.WS_CAPTION # remove border and titlebar + win32gui.SetWindowLong(hwnd, win32con.GWL_STYLE, style) + + titlebar_height = win32gui.GetWindowRect(hwnd)[3] - win32gui.GetClientRect(hwnd)[3] + # move the window up to compensate for the lack of a titlebar + # the two flags result in the second, fifth and six arguments being ignored so we don't have to worry about them + win32gui.SetWindowPos(hwnd, 0, 0, -titlebar_height, 0, 0, win32con.SWP_NOSIZE | win32con.SWP_NOZORDER) diff --git a/flair/inspect/events.py b/flair/inspect/events.py new file mode 100644 index 0000000..c9c9c27 --- /dev/null +++ b/flair/inspect/events.py @@ -0,0 +1,49 @@ +""" + Copyright (C) 2016, 2017, 2020 biqqles. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" + + +class Signal(set): + """A simple event object inspired by Qt's signals and slots mechanism. + + Instances of this class can be 'connected' to functions which will be called once it is 'emitted'.""" + def __init__(self, **payload): + # payload is a description of the data this field will emit + super().__init__() + self.payload = payload + + def connect(self, function): + self.add(function) + + def disconnect(self, function): + if function in self: + self.remove(function) + + def disconnect_all(self): + self.clear() + + def emit(self, **payload): + if not set(payload) == set(self.payload): # compare keys + raise ValueError('Payload does not conform to the schema specified for this signal') + for function in self: # todo consider a more helpful error message here + function(*payload.values()) + + +# /Name/ # /Emitted when/ # /Parameter(s)/ +character_changed = Signal(name=str) # New character loaded # New character name +account_changed = Signal(account=str) # Active (registry) account changed # New account code +system_changed = Signal(system=str) # New system entered # New system display name +docked = Signal(base=str) # Docked base (respawn point) changed # New base display name +undocked = Signal() # Undocked from base # N/A +credits_changed = Signal(balance=int) # Credit balance changed # New credit balance +message_sent = Signal(message=str) # New message sent by player # Message text +chat_box_opened = Signal() # Chat box opened # N/A +chat_box_closed = Signal(message_sent=bool) # Chat box closed # Whether message sent +freelancer_started = Signal() # Freelancer process launched # N/A +freelancer_stopped = Signal() # Freelancer process closed # N/A +switched_to_foreground = Signal() # Freelancer switched to foreground # N/A +switched_to_background = Signal() # Freelancer switched to background # N/A diff --git a/flair/inspect/state.py b/flair/inspect/state.py new file mode 100644 index 0000000..99e102c --- /dev/null +++ b/flair/inspect/state.py @@ -0,0 +1,235 @@ +""" + Copyright (C) 2016, 2017, 2020 biqqles. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" +import threading +from typing import Optional + +import flint as fl +from flint.maps import PosVector + +from ..inspect import events +from ..hook import window, process, storage + + +class state_variable: + def __init__(self, initially, passive=False): + """Handle arguments to decorator. + `initially`: the initial value of this variable + `passive`: if true, signifies that the getter does not compute the value of the variable - i.e. it is set + elsewhere.""" + self.default = initially + self.last = initially + self.passive = passive + + def __call__(self, getter): + """Handle first function passed to decorator.""" + self.fget = getter + self.__doc__ = getter.__doc__ + self.fchanged = lambda *args: None + return self + + def __get__(self, instance, owner): + """Return the current value of the state variable. If the game is not running, this is the default value.""" + if not window.is_present(): + self.last = self.default + else: + if self.passive: + return self.last + new_value = self.fget(instance) + return new_value + return self.last + + def __set__(self, instance, value): + """Handle state variable being updated.""" + if value != self.last: + self.fchanged(instance, value, self.last) + self.last = value + + def changed(self, fchanged): + """Handle second function passed to decorator.""" + if not hasattr(self, 'fget'): + raise TypeError('A "get" function must be set before this decorator can be applied.') + self.fchanged = fchanged + return self + + +class FreelancerState: + """Many variables provided by this class are abstractions on those available from hook""" + def __init__(self, freelancer_root): + fl.paths.set_install_path(freelancer_root) + self._systems = {s.name() for s in fl.get_systems() if s.name()} + self._bases = {b.name() for b in fl.get_bases() if b.name()} + self._timer = None + self._process = 0 + self.begin_polling() + + def __str__(self): + return (f'State(running={self.running}, ' + f'foreground={self.foreground}, ' + f'chat_box={self.chat_box}, ' + f'char_loaded={self.character_loaded}, ' + f'character_name={self.name!r}, ' + f'system={self.system!r}, ' + f'base={self.base!r}, ' + f'docked={self.docked!r}, ' + f'credits={self.credits}, ' + f'pos={self.pos!r})') + + def refresh(self): + """Cause state variables to refresh themselves.""" + self.running = self.running + self.foreground = self.foreground + + if self.running: # remember to add new properties here or they will not be polled + self.character_loaded = self.character_loaded + self.name = self.name + self.credits = self.credits + self.pos = self.pos + self.docked = self.docked + self.mouseover = self.mouseover + self.chat_box = self.chat_box + + def begin_polling(self, period=1.0, print_state=True): + """Begin polling the game's state and emitting events. Called upon instantiation by default.""" + def poll(): + self.refresh() + if print_state: + print(self) + self._timer = threading.Timer(period, poll) + self._timer.start() + + self.end_polling() + poll() + + def end_polling(self): + """Stop polling the game's state and emitting events.""" + if self._timer: + self._timer.cancel() + + @state_variable(initially=False) + def running(self) -> bool: + """Whether an instance of the game is running.""" + return window.is_present() + + @running.changed + def running(self, new, last): + if new: # Freelancer has been started + self._process = process.get_process() + events.freelancer_started.emit() + else: # Freelancer has been stopped + if self._process: + self._process.close() + events.freelancer_stopped.emit() + + @state_variable(initially=False) + def foreground(self) -> bool: + """Whether an instance of the game is in the foreground and accepting input.""" + return window.is_foreground() if self.running else False + + @foreground.changed + def foreground(self, new, last): + if new: + events.switched_to_foreground.emit() + else: + events.switched_to_background.emit() + + @state_variable(initially=False) + def chat_box(self) -> bool: + """Whether the chat box is open.""" + return process.get_chat_box_state(self._process) + + @chat_box.changed + def chat_box(self, new, last): + return # handled by input.py + + @state_variable(initially='') + def account(self) -> str: + """The "name" (hash) of the currently active account, taken from the registry.""" + return storage.get_active_account_name() + + @account.changed + def account(self, new, last): + events.account_changed.emit(account=new) + + @state_variable(initially='') + def mouseover(self) -> str: + """See documentation in hook/process.""" + return process.get_mouseover(self._process) + + @mouseover.changed + def mouseover(self, new, last): + if new in self._systems: + # player has entered a new system + self.system = new + + @state_variable(initially=False) + def character_loaded(self) -> bool: + """Whether a character is currently loaded (i.e. the player is logged in), either in SP or MP.""" + return process.get_character_loaded(self._process) + + @character_loaded.changed + def character_loaded(self, new, last): + # Freelancer loads the current account from registry on server connection, so this is a good time to get it + if new: + self.account = self.account + else: + self.system = None + self.base = None + + @state_variable(initially=None) + def name(self) -> Optional[str]: + """The name of the currently active character if there is one, otherwise None.""" + return process.get_name(self._process) if self.character_loaded else None + + @name.changed + def name(self, new, last): + events.character_changed.emit(name=new) + + @state_variable(initially=None) + def credits(self) -> Optional[int]: + """The number of credits the active character has if there is one, otherwise None.""" + return process.get_credits(self._process) if self.character_loaded else None + + @credits.changed + def credits(self, new, last): + events.credits_changed.emit(balance=new) + + @state_variable(initially=None, passive=True) + def system(self) -> Optional[str]: + """The display name (e.g. "New London") of the system the active character is in if there is one, otherwise + None.""" + # set in mouseover + return + + @system.changed + def system(self, new, last): + events.system_changed.emit(system=new) + + @state_variable(initially=None, passive=True) + def base(self) -> Optional[str]: + """The display name (e.g. "The Ring") of the base the active character last docked at if there is one, + otherwise None.""" + # set in docked + return + + @state_variable(initially=None) + def docked(self) -> Optional[bool]: + """Whether the active character is presently docked at a base if there is one, otherwise None.""" + return process.get_docked(self._process) if self.character_loaded else None + + @docked.changed + def docked(self, new, last): + if new and self.mouseover in self._bases: # todo: try wait_until(self.mouseover) + self.base = self.mouseover + events.docked.emit(base=self.base) + elif not new: + events.undocked.emit() + + @state_variable(initially=None) + def pos(self) -> Optional[PosVector]: + """The position vector of the active character if there is one, otherwise None.""" + return PosVector(*process.get_position(self._process)) if self.character_loaded else None diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5d699f7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +deconstruct +flint +keyboard>=11.0 +pywin32 +Pillow diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c0aac71 --- /dev/null +++ b/setup.py @@ -0,0 +1,30 @@ +""" + Copyright (C) 2016, 2017, 2020 biqqles. + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. +""" +from setuptools import setup + +setup( + name='fl-flair', + version='0.1', + author='biqqles', + author_email='biqqles@protonmail.com', + description='A novel client-side hook for Freelancer', + long_description=open('README.md').read(), + long_description_content_type='text/markdown', + url='https://github.com/biqqles/flair', + packages=['flair'], + classifiers=[ + 'Programming Language :: Python :: 3', + 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', + 'Operating System :: Microsoft :: Windows', + 'Topic :: Games/Entertainment', + 'Intended Audience :: Developers', + 'Development Status :: 3 - Alpha' + ], + python_requires='>=3.6', + install_requires=open('requirements.txt').readlines(), +)