implement agent_release

This commit is contained in:
Benjamin Palko 2026-10-14 16:58:56 -04:00
parent 11a3488883
commit b8f96fc411
11 changed files with 576 additions and 0 deletions

52
src/README.md Normal file
View file

@ -0,0 +1,52 @@
# src — Linux reconstructions
Buildable Linux C++ ports of analysed Rusty Hearts server binaries. Each
sub-folder ports one binary and has its own README. That README covers
provenance, the Win32 → Linux substitutions, and what is not yet ported.
| Folder | Original binary | Output |
|---|---|---|
| `agent_release/` | `Agent_Release_x64.exe` | `build/agent_release/agent_release` |
These are reconstructions from decompiled code, not original source.
## Requirements
- Linux with glibc ≥ 2.29 (for `posix_spawn_file_actions_addchdir_np`)
- A C++17 compiler (tested with GCC 16.2)
- Meson ≥ 1.0 and Ninja. Run both from this repo's `mise` environment or the
system packages.
## Build
From this directory:
```sh
meson setup build # first time only
ninja -C build
```
## Common tasks
- **Rebuild after changes:** `ninja -C build`.
- **Release build:** `meson setup build-release --buildtype=release && ninja -C build-release`.
- **Clean rebuild:** `rm -rf build && meson setup build && ninja -C build`.
## Run
```sh
./build/agent_release/agent_release --help
./build/agent_release/agent_release --auto-on 1 \
--type 1:/path/to/server:/path/to/workdir --server 1:1:1
```
Stop it with Ctrl-C or SIGTERM. See `agent_release/README.md` for every
option.
`build/` is gitignored.
## Adding another binary
1. Create `<name>/` with its sources, a `meson.build` and a `README.md`. The
README must cite decomp addresses and list substitutions and gaps.
2. Add `subdir('<name>')` to `meson.build` in this directory.

View file

@ -0,0 +1,65 @@
# agent_release — Linux port of `Agent_Release_x64.exe` (process-supervisor core)
A buildable Linux reconstruction of the parts of the Agent that have been
decompiled. It is **not** the original program: names are inferred (there is no
PDB), and only the analysed functions are ported.
Source analysis:
- `decomp/Agent_Release_x64/evidence.md`
- `decomp/Agent_Release_x64/cpp/AgentProcessControl.cpp`
## Build and run
```sh
cd src
meson setup build
ninja -C build
./build/agent_release/agent_release --auto-on 1 \
--type 1:/path/to/server:/path/to/workdir --server 1:1:1
```
`--type TYPE:EXE[:WORKDIR]` maps a server type to its executable.
`--server CODE:TYPE:REVIVE` registers one managed server. By default the
server's code is passed as its argument, so the command line is `EXE CODE`.
## Win32 → Linux substitutions
| Original (address) | Win32 | Linux replacement | Behaviour difference |
|---|---|---|---|
| Single instance (`0x1400100b0`) | `CreateMutexW("Global\\RustyHearts_Agent")` | `flock(LOCK_EX\|LOCK_NB)` on `/tmp/RustyHearts_Agent.lock` | Lock file instead of a kernel object; still released on exit/crash |
| Launch (`0x1400094e0`) | `CreateProcessW(…, CREATE_NEW_CONSOLE, …, workDir)` + `STARTUPINFOW` console geometry | `posix_spawnp` + `POSIX_SPAWN_SETSID` + `addchdir_np` | No console window; command line split on whitespace (no Windows quoting rules) |
| Watcher (`0x140008c90`) | `GetExitCodeProcess` polled every 10 ms | blocking `waitpid` on a detached `std::thread` | No polling; child reaped (fixes original handle leak); PID passed by value (fixes original stack-pointer race) |
| Kill (`0x140009780`) | Toolhelp32 snapshot + `OpenProcess` + `TerminateProcess(h,0)` | `/proc/*/cmdline` argv[0] basename, `strcasecmp`, `kill(SIGKILL)` | Same: first match only, case-insensitive; no snapshot leak |
| Main loop timer (`0x1400037a0`) | `QueryPerformanceCounter` (`TimeSourceHRWin32`) | `std::chrono::steady_clock` | — |
| Thread start | `_beginthreadex` | `std::thread` | — |
| `Sleep` | `Sleep` | `std::this_thread::sleep_for` | — |
| Shutdown flag | console menu (`FUN_140001dc0`, not analysed) | `SIGINT` / `SIGTERM` | Different trigger |
Kept from the original:
- the 1-second sleep after each launch attempt;
- the 260-character (`MAX_PATH`) command-line limit;
- the log strings.
## Not ported (unknown or proprietary)
- **ProudNet networking / RMI to AgentManager and game servers.** ProudNet is
proprietary and the protocol is undecoded. `IManagerSession` is a logging
stub at the points where the original sends RMI messages (proxy vtable
slots `+0x40` and `+0x50`).
- **XML config (`option\*.xml`, TinyXML).** Field mapping is not yet recovered,
so command-line flags stand in for it.
- **Launch-argument builder `FUN_140027540`.** Its output is unknown, so only
the "server code as argument" mode is implemented.
- **Exit follow-ups.**
- The `type == 1` exit follow-up (`FUN_140009f20`) is not ported.
- The identity of the type `0x18` process killed at startup is unknown.
`--kill-on-start` stands in for it.
- **Locking.** The original's locking around the server table is not visible
in the analysed code. This port uses a recursive mutex.
## Verified (2026-10-14, Linux)
Smoke test, using `sleep` as the managed server: auto-start after the delay,
revive on exit, second-instance refusal, case-insensitive kill by name,
clean SIGTERM shutdown. This tests the port only. It says nothing about the
original binary's runtime behaviour.

125
src/agent_release/agent.cpp Normal file
View file

@ -0,0 +1,125 @@
// agent.cpp — logic follows the Ghidra pseudocode cited per function.
#include "agent.hpp"
#include "platform.hpp"
#include <chrono>
#include <cstdio>
namespace agent {
void LoggingManagerSession::NotifyServerState(const ServerEntry& e)
{
std::printf("[RMI->AgentManager] ServerState code=%d pid=%d running=%d\n",
e.code, static_cast<int>(e.pid), e.running ? 1 : 0);
}
void LoggingManagerSession::NotifyServerStopped(const ServerEntry& e)
{
std::printf("[RMI->AgentManager] ServerStopped code=%d\n", e.code);
}
void LoggingManagerSession::SendLog(const std::string& text)
{
std::printf("[RMI->AgentManager] Log: %s\n", text.c_str());
}
Agent::Agent(Options opts, std::map<uint8_t, ServerTypeInfo> types,
std::unique_ptr<IManagerSession> session)
: opts_(std::move(opts)), types_(std::move(types)), session_(std::move(session)) {}
void Agent::AddServer(const ServerEntry& e)
{
std::lock_guard<std::recursive_mutex> g(mu_);
servers_[e.code] = e;
}
std::string Agent::BuildCommandLine(const ServerEntry& e) const
{
auto t = types_.find(e.type);
std::string cmd = (t == types_.end() ? std::string() : t->second.exePath) + " ";
if (opts_.useCodeAsArgument)
cmd += std::to_string(e.code);
// else: original appends FUN_140027540's output — contents UNKNOWN, not ported.
return cmd;
}
// 0x140008ea0
void Agent::ServerOn(int32_t code)
{
std::lock_guard<std::recursive_mutex> g(mu_);
auto it = servers_.find(code);
if (it == servers_.end()) {
std::printf("Error - NOT EXIST SERVER : %d \n", code);
return;
}
ServerEntry& e = it->second;
if (e.running) return;
e.running = true;
std::string cmd = BuildCommandLine(e);
auto t = types_.find(e.type);
std::string workDir = t == types_.end() ? std::string() : t->second.workDir;
pid_t pid = 0;
if (cmd.size() < 260 && (pid = platform::LaunchProcess(cmd, workDir)) > 0) { // MAX_PATH check kept
e.pid = pid;
e.running = true;
session_->NotifyServerState(e);
platform::WatchProcess(pid, [this](pid_t p) { OnProcessExit(p); });
} else {
e.running = false;
session_->SendLog("FAILED SERVER ON " + opts_.agentName + " ");
}
}
void Agent::AllServerOn()
{
std::lock_guard<std::recursive_mutex> g(mu_);
std::printf("All Server On. \n");
for (auto& kv : servers_) ServerOn(kv.first);
}
// 0x140008c90 (after the wait loop)
void Agent::OnProcessExit(pid_t pid)
{
std::lock_guard<std::recursive_mutex> g(mu_);
ServerEntry* e = nullptr;
for (auto& kv : servers_)
if (kv.second.pid == pid) { e = &kv.second; break; }
if (!e) { std::printf("NOT FIND PROCESS ID \n"); return; }
e->running = false;
session_->NotifyServerState(*e);
session_->NotifyServerStopped(*e);
if (running_ && (e->revive || opts_.reviveAll))
ServerOn(e->code);
// type==1 follow-up (FUN_140009f20) — purpose UNKNOWN, not ported.
}
// 0x1400037a0 MagiAgentServer::Run — network setup omitted (ProudNet).
int Agent::Run()
{
using clock = std::chrono::steady_clock;
std::printf("# Agent Startup #\n\n+ Prepare Startup\n");
std::printf("\n # Agent Running... #\n");
if (!opts_.killOnStart.empty()) platform::KillProcessByName(opts_.killOnStart);
const auto t0 = clock::now();
auto seconds = [&] { return std::chrono::duration<double>(clock::now() - t0).count(); };
bool autoOnPending = true;
double lastTick = 0;
while (running_) {
double now = seconds();
if (opts_.autoServerOnDelay > 0 && now > opts_.autoServerOnDelay && autoOnPending) {
autoOnPending = false;
std::printf("Start Auto Server On. \n");
AllServerOn();
}
if (now - lastTick >= 0.1) lastTick = now; // 10 Hz tick: network update (not ported)
platform::SleepMs(1);
}
std::printf("+ Prepare Stop\n# Agent Stoped... #\n");
return 0;
}
} // namespace agent

View file

@ -0,0 +1,82 @@
// agent.hpp — Linux port of the process-supervisor core of Agent_Release_x64.exe.
// Reconstructed from decomp/Agent_Release_x64/cpp/AgentProcessControl.cpp.
// Names are inferred (no PDB). See README.md for what is and is not ported.
#pragma once
#include <sys/types.h>
#include <atomic>
#include <cstdint>
#include <map>
#include <memory>
#include <mutex>
#include <string>
namespace agent {
// Per-server record (original: hash map at 0x140162d60, ServerEntry offsets
// +0x04 code, +0x0c type, +0xa4 pid, +0xc4 running, +0xd5 revive).
struct ServerEntry {
int32_t code = 0;
uint8_t type = 0;
pid_t pid = 0;
bool running = false;
bool revive = false;
};
// Server-type table (original: vector at 0x140162d58, type -> exe path).
struct ServerTypeInfo {
std::string exePath; // may include fixed arguments
std::string workDir; // original derives it from the exe path (FUN_14002fe90)
};
// Agent options (original: singleton at 0x140162d68).
struct Options {
std::string agentName = "AGENT"; // +0x030
bool useCodeAsArgument = true; // +0x152
bool reviveAll = false; // +0x171
double autoServerOnDelay = 0.0; // +0x178, seconds; <= 0 disables
std::string killOnStart; // exe of server type 0x18 (identity UNKNOWN)
};
// Stand-in for the ProudNet RMI proxy to AgentManager
// (AGENT2AGENTMANAGER_PROTOCOL::Proxy, vtable slots +0x40 / +0x50).
// ProudNet is proprietary and the wire protocol is not yet decoded, so this
// interface only logs. Replace with a real transport once the protocol is known.
class IManagerSession {
public:
virtual ~IManagerSession() = default;
virtual void NotifyServerState(const ServerEntry& e) = 0; // slot +0x40
virtual void NotifyServerStopped(const ServerEntry& e) = 0; // slot +0x50
virtual void SendLog(const std::string& text) = 0;
};
class LoggingManagerSession : public IManagerSession {
public:
void NotifyServerState(const ServerEntry& e) override;
void NotifyServerStopped(const ServerEntry& e) override;
void SendLog(const std::string& text) override;
};
class Agent {
public:
Agent(Options opts, std::map<uint8_t, ServerTypeInfo> types,
std::unique_ptr<IManagerSession> session);
void AddServer(const ServerEntry& e);
void ServerOn(int32_t code); // 0x140008ea0
void AllServerOn(); // 0x140009bd0 (inferred: ServerOn for every entry)
int Run(); // 0x1400037a0 MagiAgentServer::Run
void Stop() { running_ = false; }
private:
void OnProcessExit(pid_t pid); // body of watcher thread 0x140008c90
std::string BuildCommandLine(const ServerEntry& e) const;
Options opts_;
std::map<uint8_t, ServerTypeInfo> types_;
std::unique_ptr<IManagerSession> session_;
std::map<int32_t, ServerEntry> servers_;
std::recursive_mutex mu_; // original has no visible lock here (UNKNOWN)
std::atomic<bool> running_{true};
};
} // namespace agent

View file

@ -0,0 +1,70 @@
// main.cpp — entry point. The original reads option\*.xml (format not yet
// recovered); this port takes the equivalent settings on the command line.
#include "agent.hpp"
#include "platform.hpp"
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <string>
static agent::Agent* g_agent = nullptr;
static void OnSignal(int) { if (g_agent) g_agent->Stop(); }
static void Usage()
{
std::printf(
"usage: agent_release [options] --server CODE:TYPE:REVIVE --type TYPE:EXE[:WORKDIR] ...\n"
" --auto-on SEC start all servers SEC seconds after startup\n"
" --revive-all relaunch any server that exits\n"
" --no-code-arg do not pass the server code as argv[1]\n"
" --kill-on-start EXE kill the first process named EXE before running\n"
" --name NAME agent name used in log messages\n");
}
int main(int argc, char** argv)
{
agent::Options opts;
std::map<uint8_t, agent::ServerTypeInfo> types;
std::vector<agent::ServerEntry> servers;
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
auto next = [&]() -> std::string {
if (i + 1 >= argc) { Usage(); std::exit(2); }
return argv[++i];
};
if (a == "--auto-on") opts.autoServerOnDelay = std::stod(next());
else if (a == "--revive-all") opts.reviveAll = true;
else if (a == "--no-code-arg") opts.useCodeAsArgument = false;
else if (a == "--kill-on-start") opts.killOnStart = next();
else if (a == "--name") opts.agentName = next();
else if (a == "--type") {
std::string v = next();
auto p1 = v.find(':');
if (p1 == std::string::npos) { Usage(); return 2; }
auto p2 = v.find(':', p1 + 1);
agent::ServerTypeInfo t;
t.exePath = v.substr(p1 + 1, p2 == std::string::npos ? std::string::npos : p2 - p1 - 1);
if (p2 != std::string::npos) t.workDir = v.substr(p2 + 1);
types[static_cast<uint8_t>(std::stoi(v.substr(0, p1)))] = t;
} else if (a == "--server") {
std::string v = next();
agent::ServerEntry e;
int code = 0, type = 0, revive = 0;
if (std::sscanf(v.c_str(), "%d:%d:%d", &code, &type, &revive) < 2) { Usage(); return 2; }
e.code = code; e.type = static_cast<uint8_t>(type); e.revive = revive != 0;
servers.push_back(e);
} else { Usage(); return a == "-h" || a == "--help" ? 0 : 2; }
}
platform::SingleInstanceLock lock("RustyHearts_Agent"); // Global\RustyHearts_Agent
if (!lock.acquired()) return 1;
agent::Agent ag(opts, types, std::make_unique<agent::LoggingManagerSession>());
for (auto& e : servers) ag.AddServer(e);
g_agent = &ag;
std::signal(SIGINT, OnSignal);
std::signal(SIGTERM, OnSignal);
return ag.Run();
}

View file

@ -0,0 +1,6 @@
threads = dependency('threads')
executable('agent_release',
['main.cpp', 'agent.cpp', 'platform.cpp'],
dependencies : threads,
install : false)

View file

@ -0,0 +1,126 @@
// platform.cpp — Linux implementations; Win32 originals cited per function.
#include "platform.hpp"
#include <dirent.h>
#include <fcntl.h>
#include <signal.h>
#include <spawn.h>
#include <strings.h>
#include <sys/file.h>
#include <sys/wait.h>
#include <unistd.h>
#include <cerrno>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <sstream>
#include <thread>
#include <vector>
extern char** environ;
namespace platform {
SingleInstanceLock::SingleInstanceLock(const std::string& name)
{
// "Global\\" kernel-object namespace -> machine-wide lock file in /tmp.
std::string path = "/tmp/" + name + ".lock";
fd_ = ::open(path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0644);
if (fd_ < 0) {
std::printf("ERROR_ACCESS_DENIED!!! %s \n", path.c_str());
} else if (::flock(fd_, LOCK_EX | LOCK_NB) != 0) {
std::printf("ERROR_ALREADY_EXISTS!!! %s \n", path.c_str());
::close(fd_);
fd_ = -1;
}
if (fd_ < 0) SleepMs(3000); // original sleeps 3 s before failing
}
SingleInstanceLock::~SingleInstanceLock()
{
if (fd_ >= 0) ::close(fd_);
}
pid_t LaunchProcess(const std::string& commandLine, const std::string& workDir)
{
std::istringstream in(commandLine);
std::vector<std::string> args;
for (std::string a; in >> a;) args.push_back(a);
if (args.empty()) return 0;
std::vector<char*> argv;
for (auto& a : args) argv.push_back(a.data());
argv.push_back(nullptr);
posix_spawn_file_actions_t fa;
posix_spawn_file_actions_init(&fa);
if (!workDir.empty()) posix_spawn_file_actions_addchdir_np(&fa, workDir.c_str());
posix_spawnattr_t attr;
posix_spawnattr_init(&attr);
posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETSID); // ~ CREATE_NEW_CONSOLE
pid_t pid = 0;
int rc = posix_spawnp(&pid, argv[0], &fa, &attr, argv.data(), environ);
posix_spawnattr_destroy(&attr);
posix_spawn_file_actions_destroy(&fa);
if (rc != 0) {
std::printf("=======Server Fail=======\n");
std::printf("Path\t: [%s] \n", workDir.c_str());
std::printf("File\t: [%s] (%s)\n", commandLine.c_str(), std::strerror(rc));
pid = 0;
} else {
std::printf("=======ServerOn=======\n");
}
std::printf("//////////////////////////////////////////////////////////////////////////\n");
SleepMs(1000); // kept: original sleeps 1 s after every launch attempt
return pid;
}
void WatchProcess(pid_t pid, std::function<void(pid_t)> onExit)
{
// pid passed by value: avoids the original's stack-pointer race.
std::thread([pid, onExit = std::move(onExit)] {
int status = 0;
while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) {}
onExit(pid); // waitpid reaps the child: no handle/zombie leak
}).detach();
}
static std::string ProcArgv0Basename(const char* pidDir)
{
std::ifstream f(std::string("/proc/") + pidDir + "/cmdline", std::ios::binary);
std::string argv0;
std::getline(f, argv0, '\0');
auto slash = argv0.find_last_of('/');
return slash == std::string::npos ? argv0 : argv0.substr(slash + 1);
}
bool KillProcessByName(const std::string& exeName)
{
if (exeName.empty()) return false;
DIR* d = ::opendir("/proc");
if (!d) return false;
bool killed = false;
while (dirent* e = ::readdir(d)) {
char* end = nullptr;
long pid = std::strtol(e->d_name, &end, 10);
if (*end != '\0' || pid <= 0 || pid == ::getpid()) continue;
if (::strcasecmp(ProcArgv0Basename(e->d_name).c_str(), exeName.c_str()) != 0) continue;
if (::kill(static_cast<pid_t>(pid), SIGKILL) == 0) {
std::printf("Process Kill.!!! [%s] \n", exeName.c_str());
killed = true;
break; // original stops at the first match
}
}
::closedir(d);
return killed;
}
void SleepMs(unsigned ms)
{
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
}
} // namespace platform

View file

@ -0,0 +1,39 @@
// platform.hpp — Linux substitutes for the Win32 calls used by
// Agent_Release_x64.exe (see decomp/Agent_Release_x64/cpp/AgentProcessControl.cpp).
#pragma once
#include <sys/types.h>
#include <functional>
#include <string>
namespace platform {
// Replaces CreateMutexW(L"Global\\RustyHearts_Agent") (0x1400100b0).
// Uses an exclusive flock() on a lock file; released automatically on exit.
class SingleInstanceLock {
public:
explicit SingleInstanceLock(const std::string& name);
~SingleInstanceLock();
bool acquired() const { return fd_ >= 0; }
private:
int fd_ = -1;
};
// Replaces CreateProcessW(..., CREATE_NEW_CONSOLE, ..., workDir) (0x1400094e0).
// commandLine is split on whitespace (the original passes one string to Windows).
// The child gets its own session (setsid) as the nearest analogue of a new console.
// Returns pid > 0, or 0 on failure (same contract as the original).
pid_t LaunchProcess(const std::string& commandLine, const std::string& workDir);
// Replaces the watcher's GetExitCodeProcess polling loop (0x140008c90) with a
// blocking waitpid(). Calls onExit(pid) on a detached thread once the child exits.
void WatchProcess(pid_t pid, std::function<void(pid_t)> onExit);
// Replaces Toolhelp32 + OpenProcess + TerminateProcess (0x140009780).
// Scans /proc, matches basename of argv[0] case-insensitively, kills the FIRST
// match with SIGKILL (TerminateProcess is unconditional), returns true if killed.
bool KillProcessByName(const std::string& exeName);
// Replaces Sleep(ms).
void SleepMs(unsigned ms);
} // namespace platform

7
src/meson.build Normal file
View file

@ -0,0 +1,7 @@
# Linux reconstructions of the Rusty Hearts server binaries.
# Each sub-folder ports one binary; see its README.md for provenance and gaps.
project('rusty-hearts-recon', 'cpp',
version : '0.1.0',
default_options : ['cpp_std=c++17', 'warning_level=3', 'buildtype=debugoptimized'])
subdir('agent_release')