Skip to content

Commit b30b09c

Browse files
committed
feat(Lua): Option to auto-reload individual mods when a change is detected in the Scripts directory
1 parent 4acf640 commit b30b09c

File tree

24 files changed

+658
-44
lines changed

24 files changed

+658
-44
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#pragma once
2+
3+
// A thread-safe type (if used correctly) used to watch for filesystem changes.
4+
// Create a FilesystemWatcher, and call 'add' on it once for each file or directory that you want to watch.
5+
// You can use Sync.hpp to synchronize between the main thread and the loader thread, useful for automatic hot-reloading.
6+
7+
#include <filesystem>
8+
#include <utility>
9+
#include <vector>
10+
#include <thread>
11+
#include <stop_token>
12+
#include <chrono>
13+
#include <functional>
14+
#include <cstdint>
15+
16+
#include <Sync.hpp>
17+
18+
namespace RC
19+
{
20+
enum class FilesystemWatcherThreadType
21+
{
22+
Main = 0,
23+
Loader = 1,
24+
};
25+
26+
struct FilesystemWatch
27+
{
28+
public:
29+
using NotificationFunctionType = std::function<void(const std::filesystem::path& file, bool match_all)>;
30+
NotificationFunctionType notify{};
31+
std::string name{};
32+
33+
public:
34+
FilesystemWatch() = default;
35+
FilesystemWatch(NotificationFunctionType notify, std::string name) : notify{std::move(notify)}, name{std::move(name)}
36+
{
37+
}
38+
FilesystemWatch(NotificationFunctionType notify, const std::filesystem::path& name) : notify{std::move(notify)}, name{name.filename().string()}
39+
{
40+
}
41+
};
42+
43+
// clang-format off
44+
enum class LibReloaderState
45+
{
46+
Idle = -1, // Main thread is executing, loader thread is waiting for notification from the OS.
47+
ReloadRequested_WaitForMain = 0, // Loader got notification from OS, and is now paused until main thread replies it's safe to reload the .dll/.so file.
48+
// This allows the main thread to finish executing its current frame before the .dll/.so file is unloaded.
49+
// Without this, the .dll/.so file could be unloaded at the same time that it's being used which would cause a SIGSEGV or SIGBUS error.
50+
ReloadCanStart_WaitForLoader = 1, // Main thread received notification from loader, and is now in a safe state to reload, and is now paused until loader replies it's safe to continue executing.
51+
ReloadCompleted = 2, // Loader thread has finished reloading the .dll/.so file, and now notifies main of such and then immediately goes into idle state.
52+
// When the main thread receives the notification from the loader, it also go into idle state.
53+
};
54+
// clang-format on
55+
56+
class FilesystemWatcher
57+
{
58+
public:
59+
std::vector<std::filesystem::path> m_paths{};
60+
void* m_handle{};
61+
std::vector<void*> m_handles{};
62+
std::vector<FilesystemWatch> m_watches{};
63+
std::stop_source m_stop_source{};
64+
std::thread m_polling_thread{};
65+
ThreadState m_state{};
66+
std::chrono::time_point<std::chrono::high_resolution_clock> m_last_notification{std::chrono::high_resolution_clock::now()};
67+
std::chrono::milliseconds m_min_duration_between_notifications{4000};
68+
69+
private:
70+
static constexpr int32_t s_polling_timeout_ms = 100;
71+
72+
public:
73+
FilesystemWatcher() = default;
74+
FilesystemWatcher(FilesystemWatcher&& other) noexcept;
75+
FilesystemWatcher& operator=(FilesystemWatcher&& other) noexcept;
76+
~FilesystemWatcher();
77+
78+
public:
79+
auto add_dir(const std::filesystem::path& dir) -> void;
80+
auto start_async_polling(const FilesystemWatch::NotificationFunctionType& notify) -> void;
81+
auto get_thread_state() -> ThreadState&
82+
{
83+
return m_state;
84+
}
85+
86+
private:
87+
auto poll(std::stop_token) -> void;
88+
89+
private:
90+
friend auto init_filesystem_watcher(FilesystemWatcher&, const std::filesystem::path&) -> void;
91+
};
92+
} // namespace RC
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
3+
NOTE THAT THIS FILE IS OUTDATED!!! IT HASN'T BEEN UPDATED TO MATCH THE BEHAVIOR OF THE Windows VERSION!
4+
THIS FILE PROBABLY DOESN'T EVEN COMPILE IN ITS CURRENT STATE!
5+
6+
7+
*/
8+
9+
#include <bit>
10+
#include <iostream>
11+
#include <cstdint>
12+
#include <cstring>
13+
#include <sys/inotify.h>
14+
#include <sys/poll.h>
15+
#include <unistd.h>
16+
17+
namespace RC
18+
{
19+
auto handle_to_fd(void* handle) -> int32_t
20+
{
21+
return static_cast<int32_t>(reinterpret_cast<uintptr_t>(handle));
22+
}
23+
24+
auto init_filesystem_watcher(FilesystemWatcher& watcher, const std::filesystem::path& path) -> void
25+
{
26+
watcher.m_path = path;
27+
auto handle = inotify_init1(IN_NONBLOCK);
28+
watcher.m_handle = reinterpret_cast<void*>(handle);
29+
auto file_descriptor = handle;
30+
if (file_descriptor == -1)
31+
{
32+
Output::send<LogLevel::Error>(STR("Error watching '{}': {}"), path.string(), std::strerror(errno));
33+
return;
34+
}
35+
if (inotify_add_watch(file_descriptor, path.c_str(), IN_CREATE) == -1)
36+
{
37+
Output::send<LogLevel::Error>(STR("Error watching '{}': {}"), path.string(), std::strerror(errno));
38+
return;
39+
}
40+
}
41+
42+
FilesystemWatcher::FilesystemWatcher(const std::filesystem::path& path)
43+
{
44+
init_filesystem_watcher(*this, path);
45+
}
46+
47+
FilesystemWatcher::FilesystemWatcher(const std::filesystem::path& path, std::chrono::milliseconds min_duration_between_notifications)
48+
: m_min_duration_between_notifications{min_duration_between_notifications}
49+
{
50+
init_filesystem_watcher(*this, path);
51+
}
52+
53+
FilesystemWatcher::~FilesystemWatcher()
54+
{
55+
m_stop_source.request_stop();
56+
m_polling_thread.join();
57+
if (m_handle)
58+
{
59+
close(handle_to_fd(m_handle));
60+
}
61+
}
62+
63+
auto FilesystemWatcher::poll(std::stop_token stop_token) -> void
64+
{
65+
while (!stop_token.stop_requested())
66+
{
67+
pollfd file_descriptors[2]{};
68+
file_descriptors[0].fd = STDIN_FILENO;
69+
file_descriptors[0].events = POLLIN;
70+
file_descriptors[1].fd = handle_to_fd(m_handle);
71+
file_descriptors[1].events = POLLIN;
72+
73+
auto poll_num = ::poll(file_descriptors, 2, s_polling_timeout_ms);
74+
if (poll_num == 0 && stop_token.stop_requested())
75+
{
76+
break;
77+
}
78+
else if (poll_num == -1)
79+
{
80+
if (errno == EINTR)
81+
{
82+
return;
83+
}
84+
else
85+
{
86+
Output::send<LogLevel::Error>(STR("Error: {}"), std::strerror(errno));
87+
std::exit(EXIT_FAILURE);
88+
}
89+
}
90+
else if (poll_num > 0)
91+
{
92+
if (file_descriptors[0].revents & POLLIN)
93+
{
94+
char buffer{};
95+
while (read(STDIN_FILENO, &buffer, 1) > 0 && buffer != '\n')
96+
{
97+
continue;
98+
}
99+
}
100+
if (file_descriptors[1].revents & POLLIN)
101+
{
102+
while (true)
103+
{
104+
// See https://www.man7.org/linux/man-pages/man7/inotify.7.html#EXAMPLES for why this is aligned like this.
105+
char buffer[4096] __attribute__((aligned(__alignof__(struct inotify_event))));
106+
auto length = read(handle_to_fd(m_handle), buffer, sizeof(buffer));
107+
if (length == -1 && errno != EAGAIN)
108+
{
109+
Output::send<LogLevel::Error>(STR("Error: {}"), std::strerror(errno));
110+
std::exit(EXIT_FAILURE);
111+
}
112+
else if (length <= 0)
113+
{
114+
break;
115+
}
116+
const inotify_event* event{};
117+
for (auto ptr = buffer; ptr < buffer + length; ptr += sizeof(inotify_event) + event->len)
118+
{
119+
event = std::bit_cast<const inotify_event*>(ptr);
120+
if (event->mask & IN_CREATE && event->len > 0)
121+
{
122+
std::string name_string{event->name};
123+
// Attempting to allow non-standard names for libraries on Linux.
124+
// The standard name is lib<Name>.so.
125+
if (name_string.starts_with("lib") && name_string.ends_with(".so"))
126+
{
127+
name_string.erase(0, 3);
128+
}
129+
auto name = std::filesystem::path{name_string};
130+
name.replace_extension();
131+
for (const auto& watch : m_watches)
132+
{
133+
if (watch.name == "*" || name == watch.name)
134+
{
135+
auto now = std::chrono::high_resolution_clock::now();
136+
if (now - m_last_notification < m_min_duration_between_notifications)
137+
{
138+
continue;
139+
}
140+
m_last_notification = now;
141+
watch.notify(std::filesystem::path{});
142+
}
143+
}
144+
}
145+
}
146+
}
147+
}
148+
}
149+
}
150+
}
151+
} // namespace RC
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#include <future>
2+
#include <ranges>
3+
4+
#include <Windows.h>
5+
6+
#include <DynamicOutput/DynamicOutput.hpp>
7+
#include <Helpers/String.hpp>
8+
9+
namespace RC
10+
{
11+
struct Data
12+
{
13+
HANDLE handle{};
14+
std::vector<uint8_t> buffer{};
15+
std::filesystem::path path{};
16+
Data()
17+
{
18+
buffer.resize(1000);
19+
}
20+
};
21+
22+
auto init_filesystem_watcher(FilesystemWatcher& watcher, const std::filesystem::path& path) -> void
23+
{
24+
auto data = new Data{};
25+
watcher.m_handle = data;
26+
watcher.m_handles.emplace_back(static_cast<void*>(data));
27+
data->handle = CreateFileW(path.native().c_str(),
28+
FILE_LIST_DIRECTORY,
29+
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
30+
nullptr,
31+
OPEN_EXISTING,
32+
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED,
33+
nullptr);
34+
data->path = path;
35+
}
36+
37+
FilesystemWatcher::~FilesystemWatcher()
38+
{
39+
m_stop_source.request_stop();
40+
m_polling_thread.join();
41+
auto data = static_cast<Data*>(m_handle);
42+
if (data && data->handle)
43+
{
44+
FindCloseChangeNotification(data->handle);
45+
delete data;
46+
m_handle = nullptr;
47+
}
48+
}
49+
50+
auto FilesystemWatcher::poll(std::stop_token stop_token) -> void
51+
{
52+
std::vector<HANDLE> handles{};
53+
for (const auto& handle_data_raw : m_handles)
54+
{
55+
auto handle_data = static_cast<Data*>(handle_data_raw);
56+
handles.emplace_back(FindFirstChangeNotificationW(handle_data->path.wstring().c_str(), false, FILE_NOTIFY_CHANGE_LAST_WRITE));
57+
if (handles.back() == INVALID_HANDLE_VALUE)
58+
{
59+
Output::send<LogLevel::Error>(STR("ERROR: FindFirstChangeNotification function failed.\n"));
60+
return;
61+
}
62+
}
63+
64+
std::vector<std::future<void>> futures{};
65+
static constexpr int32_t s_max_objects_per_thread = MAXIMUM_WAIT_OBJECTS;
66+
const auto use_single_thread = handles.size() <= s_max_objects_per_thread;
67+
futures.resize(use_single_thread ? 1 : handles.size() / s_max_objects_per_thread);
68+
69+
size_t range_start{};
70+
for (const auto& [index, future] : std::ranges::enumerate_view(futures))
71+
{
72+
if (stop_token.stop_requested())
73+
{
74+
break;
75+
}
76+
const auto data = &handles[range_start];
77+
const auto last_chunk = handles.size() <= s_max_objects_per_thread;
78+
const auto size = last_chunk ? handles.size() : s_max_objects_per_thread;
79+
Output::send<LogLevel::Verbose>(STR("Creating filesystem watcher {} with range {}-{}, range_start: {}, data: {}\n"), index, range_start, range_start + size, range_start, (void*)data);
80+
future = std::async(std::launch::async, [](FilesystemWatcher* watcher, std::vector<HANDLE>* in_handles, const HANDLE* data, const size_t size, const size_t range_start, std::stop_token* stop_token) {
81+
while (!stop_token->stop_requested())
82+
{
83+
auto status = WaitForMultipleObjects(size, data, false, INFINITE);
84+
if (status == WAIT_TIMEOUT || status == WAIT_ABANDONED_0 || status == WAIT_FAILED)
85+
{
86+
continue;
87+
}
88+
auto index = status + range_start;
89+
auto handle_data = static_cast<Data*>(watcher->m_handles[index]);
90+
for (const auto& watch : watcher->m_watches)
91+
{
92+
// The intent here is to allow notifiers based on the file that was changed.
93+
// But because the FindFirst/NextChangeNotification APIs don't allow you to retrieve any information about what has changed, we can't do that.
94+
// Instead, we allow everything through via the "*" option.
95+
// When/if this code is revamped to use ReadDirectoryChangesW, we can retrieve the file name and stop bypassing with "*".
96+
// For now, if the name of the watcher notifier isn't "*", the watch will never get notified.
97+
bool match_all = watch.name == "*";
98+
if (match_all/* || watch.name == name_no_extension*/)
99+
{
100+
auto now = std::chrono::high_resolution_clock::now();
101+
if (now - watcher->m_last_notification < watcher->m_min_duration_between_notifications)
102+
{
103+
continue;
104+
}
105+
watcher->m_last_notification = now;
106+
// TODO: If 'match_all' is true, we don't have any file information besides what was registered for the watch.
107+
// It would be useful for the user to have this information.
108+
// We never actully have this information right now because the FindFirst/NextChangeNotification APIs don't allow you to know what changed.
109+
// The ReadDirectoryChangesW API does, but it's a more complicated API.
110+
watch.notify(handle_data->path, match_all);
111+
}
112+
}
113+
if (!FindNextChangeNotification((*in_handles)[index]))
114+
{
115+
Output::send<LogLevel::Error>(STR("ERROR: FindNextChangeNotification function failed. Code: {}\n"), GetLastError());
116+
}
117+
}
118+
}, this, &handles, data, size, range_start, &stop_token);
119+
range_start += s_max_objects_per_thread;
120+
}
121+
122+
// Note that we'll only ever execute this code if all the futures have returned.
123+
// Futures can be triggered to return via the stop_token passed to this function.
124+
// This stop_token is triggered when FilesystemWatcher goes out of scope.
125+
for (const auto& [index, future] : std::ranges::enumerate_view(futures))
126+
{
127+
future.wait();
128+
}
129+
}
130+
} // namespace RC

UE4SS/include/Mod/Mod.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ namespace RC
4848

4949
public:
5050
auto get_name() const -> StringViewType;
51+
auto get_path() const -> const std::filesystem::path&;
5152

5253
virtual auto start_mod() -> void = 0;
5354
virtual auto uninstall() -> void = 0;

UE4SS/include/SettingsManager.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ namespace RC
2222
{
2323
bool EnableHotReloadSystem{};
2424
Input::Key HotReloadKey{Input::Key::R};
25+
bool EnableAutoReloadingLuaMods{};
2526
bool UseCache{true};
2627
bool InvalidateCacheIfDLLDiffers{true};
2728
bool EnableDebugKeyBindings{false};

0 commit comments

Comments
 (0)