2644 lines
112 KiB
C++
2644 lines
112 KiB
C++
#include <event2/event.h>
|
|
#include <event2/thread.h>
|
|
#include <pwd.h>
|
|
#include <signal.h>
|
|
#include <string.h>
|
|
|
|
#include <mutex>
|
|
#include <phosg/Arguments.hh>
|
|
#include <phosg/Filesystem.hh>
|
|
#include <phosg/JSON.hh>
|
|
#include <phosg/Math.hh>
|
|
#include <phosg/Network.hh>
|
|
#include <phosg/Strings.hh>
|
|
#include <phosg/Tools.hh>
|
|
#include <set>
|
|
#include <thread>
|
|
#include <unordered_map>
|
|
|
|
#ifdef HAVE_RESOURCE_FILE
|
|
#include "ARCodeTranslator.hh"
|
|
#else
|
|
#include "ARCodeTranslator-Stub.hh"
|
|
#endif
|
|
#include "BMLArchive.hh"
|
|
#include "CatSession.hh"
|
|
#include "Compression.hh"
|
|
#include "DCSerialNumbers.hh"
|
|
#include "DNSServer.hh"
|
|
#include "GSLArchive.hh"
|
|
#include "GVMEncoder.hh"
|
|
#include "HTTPServer.hh"
|
|
#include "IPStackSimulator.hh"
|
|
#include "Loggers.hh"
|
|
#include "NetworkAddresses.hh"
|
|
#include "PSOGCObjectGraph.hh"
|
|
#include "PSOProtocol.hh"
|
|
#include "PatchServer.hh"
|
|
#include "ProxyServer.hh"
|
|
#include "Quest.hh"
|
|
#include "QuestScript.hh"
|
|
#include "ReplaySession.hh"
|
|
#include "Revision.hh"
|
|
#include "SaveFileFormats.hh"
|
|
#include "SendCommands.hh"
|
|
#include "Server.hh"
|
|
#include "ServerShell.hh"
|
|
#include "ServerState.hh"
|
|
#include "StaticGameData.hh"
|
|
#include "Text.hh"
|
|
#include "TextIndex.hh"
|
|
|
|
using namespace std;
|
|
|
|
bool use_terminal_colors = false;
|
|
|
|
void print_version_info();
|
|
void print_usage();
|
|
|
|
template <typename T>
|
|
vector<T> parse_int_vector(const JSON& o) {
|
|
vector<T> ret;
|
|
for (const auto& x : o.as_list()) {
|
|
ret.emplace_back(x->as_int());
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
void drop_privileges(const string& username) {
|
|
if ((getuid() != 0) || (getgid() != 0)) {
|
|
throw runtime_error(string_printf(
|
|
"newserv was not started as root; can\'t switch to user %s",
|
|
username.c_str()));
|
|
}
|
|
|
|
struct passwd* pw = getpwnam(username.c_str());
|
|
if (!pw) {
|
|
string error = string_for_error(errno);
|
|
throw runtime_error(string_printf("user %s not found (%s)",
|
|
username.c_str(), error.c_str()));
|
|
}
|
|
|
|
if (setgid(pw->pw_gid) != 0) {
|
|
string error = string_for_error(errno);
|
|
throw runtime_error(string_printf("can\'t switch to group %d (%s)",
|
|
pw->pw_gid, error.c_str()));
|
|
}
|
|
if (setuid(pw->pw_uid) != 0) {
|
|
string error = string_for_error(errno);
|
|
throw runtime_error(string_printf("can\'t switch to user %d (%s)",
|
|
pw->pw_uid, error.c_str()));
|
|
}
|
|
config_log.info("Switched to user %s (%d:%d)", username.c_str(), pw->pw_uid, pw->pw_gid);
|
|
}
|
|
|
|
Version get_cli_version(Arguments& args, Version default_value = Version::UNKNOWN) {
|
|
if (args.get<bool>("pc-patch")) {
|
|
return Version::PC_PATCH;
|
|
} else if (args.get<bool>("bb-patch")) {
|
|
return Version::BB_PATCH;
|
|
} else if (args.get<bool>("dc-nte")) {
|
|
return Version::DC_NTE;
|
|
} else if (args.get<bool>("dc-proto") || args.get<bool>("dc-11-2000")) {
|
|
return Version::DC_V1_11_2000_PROTOTYPE;
|
|
} else if (args.get<bool>("dc-v1")) {
|
|
return Version::DC_V1;
|
|
} else if (args.get<bool>("dc-v2") || args.get<bool>("dc")) {
|
|
return Version::DC_V2;
|
|
} else if (args.get<bool>("pc-nte")) {
|
|
return Version::PC_NTE;
|
|
} else if (args.get<bool>("pc")) {
|
|
return Version::PC_V2;
|
|
} else if (args.get<bool>("gc-nte")) {
|
|
return Version::GC_NTE;
|
|
} else if (args.get<bool>("gc")) {
|
|
return Version::GC_V3;
|
|
} else if (args.get<bool>("xb")) {
|
|
return Version::XB_V3;
|
|
} else if (args.get<bool>("ep3-nte")) {
|
|
return Version::GC_EP3_NTE;
|
|
} else if (args.get<bool>("ep3")) {
|
|
return Version::GC_EP3;
|
|
} else if (args.get<bool>("bb")) {
|
|
return Version::BB_V4;
|
|
} else if (default_value != Version::UNKNOWN) {
|
|
return default_value;
|
|
} else {
|
|
throw runtime_error("a version option is required");
|
|
}
|
|
}
|
|
|
|
Episode get_cli_episode(Arguments& args) {
|
|
if (args.get<bool>("ep1")) {
|
|
return Episode::EP1;
|
|
} else if (args.get<bool>("ep2")) {
|
|
return Episode::EP2;
|
|
} else if (args.get<bool>("ep3")) {
|
|
return Episode::EP3;
|
|
} else if (args.get<bool>("ep4")) {
|
|
return Episode::EP4;
|
|
} else {
|
|
throw runtime_error("an episode option is required");
|
|
}
|
|
}
|
|
|
|
GameMode get_cli_game_mode(Arguments& args) {
|
|
if (args.get<bool>("battle")) {
|
|
return GameMode::BATTLE;
|
|
} else if (args.get<bool>("challenge")) {
|
|
return GameMode::CHALLENGE;
|
|
} else if (args.get<bool>("solo")) {
|
|
return GameMode::SOLO;
|
|
} else {
|
|
return GameMode::NORMAL;
|
|
}
|
|
}
|
|
|
|
uint8_t get_cli_difficulty(Arguments& args) {
|
|
if (args.get<bool>("hard")) {
|
|
return 1;
|
|
} else if (args.get<bool>("very-hard")) {
|
|
return 2;
|
|
} else if (args.get<bool>("ultimate")) {
|
|
return 3;
|
|
} else {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
string read_input_data(Arguments& args) {
|
|
const string& input_filename = args.get<string>(1, false);
|
|
|
|
string data;
|
|
if (!input_filename.empty() && (input_filename != "-")) {
|
|
data = load_file(input_filename);
|
|
} else {
|
|
data = read_all(stdin);
|
|
}
|
|
if (args.get<bool>("parse-data")) {
|
|
data = parse_data_string(data, nullptr, ParseDataFlags::ALLOW_FILES);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
bool is_text_extension(const char* extension) {
|
|
return (!strcmp(extension, "txt") || !strcmp(extension, "json"));
|
|
}
|
|
|
|
void write_output_data(Arguments& args, const void* data, size_t size, const char* extension) {
|
|
const string& input_filename = args.get<string>(1, false);
|
|
const string& output_filename = args.get<string>(2, false);
|
|
|
|
if (!output_filename.empty() && (output_filename != "-")) {
|
|
// If the output is to a specified file, write it there
|
|
save_file(output_filename, data, size);
|
|
|
|
} else if (output_filename.empty() && (output_filename != "-") && !input_filename.empty() && (input_filename != "-")) {
|
|
// If no output filename is given and an input filename is given, write to
|
|
// <input_filename>.<extension>
|
|
if (!extension) {
|
|
throw runtime_error("an output filename is required");
|
|
}
|
|
string filename = input_filename;
|
|
filename += ".";
|
|
filename += extension;
|
|
save_file(filename, data, size);
|
|
|
|
} else if (isatty(fileno(stdout)) && (!extension || !is_text_extension(extension))) {
|
|
// If stdout is a terminal and the data is not known to be text, use
|
|
// print_data to write the result
|
|
print_data(stdout, data, size);
|
|
fflush(stdout);
|
|
|
|
} else {
|
|
// If stdout is not a terminal, write the data as-is
|
|
fwritex(stdout, data, size);
|
|
fflush(stdout);
|
|
}
|
|
}
|
|
|
|
struct Action;
|
|
unordered_map<string, const Action*> all_actions;
|
|
vector<const Action*> action_order;
|
|
|
|
struct Action {
|
|
const char* name;
|
|
const char* help_text; // May be null
|
|
function<void(Arguments& args)> run;
|
|
|
|
Action(
|
|
const char* name,
|
|
const char* help_text,
|
|
function<void(Arguments& args)> run)
|
|
: name(name),
|
|
help_text(help_text),
|
|
run(run) {
|
|
auto emplace_ret = all_actions.emplace(this->name, this);
|
|
if (!emplace_ret.second) {
|
|
throw logic_error(string_printf("multiple actions with the same name: %s", this->name));
|
|
}
|
|
action_order.emplace_back(this);
|
|
}
|
|
};
|
|
|
|
Action a_help(
|
|
"help", "\
|
|
help\n\
|
|
You\'re reading it now.\n",
|
|
+[](Arguments&) -> void {
|
|
print_usage();
|
|
});
|
|
|
|
Action a_version(
|
|
"version", "\
|
|
version\n\
|
|
Show newserv\'s revision and build date.\n",
|
|
+[](Arguments&) -> void {
|
|
print_version_info();
|
|
});
|
|
|
|
static void a_compress_decompress_fn(Arguments& args) {
|
|
const auto& action = args.get<string>(0);
|
|
bool is_prs = ends_with(action, "-prs");
|
|
bool is_bc0 = ends_with(action, "-bc0");
|
|
bool is_pr2 = ends_with(action, "-pr2");
|
|
bool is_prc = ends_with(action, "-prc");
|
|
bool is_decompress = starts_with(action, "decompress-");
|
|
bool is_big_endian = args.get<bool>("big-endian");
|
|
bool is_optimal = args.get<bool>("optimal");
|
|
int8_t compression_level = args.get<int8_t>("compression-level", 0);
|
|
size_t bytes = args.get<size_t>("bytes", 0);
|
|
string seed = args.get<string>("seed");
|
|
|
|
string data = read_input_data(args);
|
|
|
|
size_t pr2_expected_size = 0;
|
|
if (is_decompress && (is_pr2 || is_prc)) {
|
|
auto decrypted = is_big_endian ? decrypt_pr2_data<true>(data) : decrypt_pr2_data<false>(data);
|
|
pr2_expected_size = decrypted.decompressed_size;
|
|
data = std::move(decrypted.compressed_data);
|
|
}
|
|
|
|
size_t input_bytes = data.size();
|
|
auto progress_fn = [&](auto, size_t input_progress, size_t, size_t output_progress) -> void {
|
|
float progress = static_cast<float>(input_progress * 100) / input_bytes;
|
|
float size_ratio = static_cast<float>(output_progress * 100) / input_progress;
|
|
fprintf(stderr, "... %zu/%zu (%g%%) => %zu (%g%%) \r",
|
|
input_progress, input_bytes, progress, output_progress, size_ratio);
|
|
};
|
|
auto optimal_progress_fn = [&](auto phase, size_t input_progress, size_t input_bytes, size_t output_progress) -> void {
|
|
const char* phase_name = name_for_enum(phase);
|
|
float progress = static_cast<float>(input_progress * 100) / input_bytes;
|
|
float size_ratio = static_cast<float>(output_progress * 100) / input_progress;
|
|
fprintf(stderr, "... [%s] %zu/%zu (%g%%) => %zu (%g%%) \r",
|
|
phase_name, input_progress, input_bytes, progress, output_progress, size_ratio);
|
|
};
|
|
|
|
uint64_t start = now();
|
|
if (!is_decompress && (is_prs || is_pr2 || is_prc)) {
|
|
if (is_optimal) {
|
|
data = prs_compress_optimal(data.data(), data.size(), optimal_progress_fn);
|
|
} else {
|
|
data = prs_compress(data, compression_level, progress_fn);
|
|
}
|
|
} else if (is_decompress && (is_prs || is_pr2 || is_prc)) {
|
|
data = prs_decompress(data, bytes, (bytes != 0));
|
|
} else if (!is_decompress && is_bc0) {
|
|
if (is_optimal) {
|
|
data = bc0_compress_optimal(data.data(), data.size(), optimal_progress_fn);
|
|
} else if (compression_level < 0) {
|
|
data = bc0_encode(data.data(), data.size());
|
|
} else {
|
|
data = bc0_compress(data, progress_fn);
|
|
}
|
|
} else if (is_decompress && is_bc0) {
|
|
data = bc0_decompress(data);
|
|
} else {
|
|
throw logic_error("invalid behavior");
|
|
}
|
|
uint64_t end = now();
|
|
string time_str = format_duration(end - start);
|
|
|
|
float size_ratio = static_cast<float>(data.size() * 100) / input_bytes;
|
|
double bytes_per_sec = input_bytes / (static_cast<double>(end - start) / 1000000.0);
|
|
string bytes_per_sec_str = format_size(bytes_per_sec);
|
|
log_info("%zu (0x%zX) bytes input => %zu (0x%zX) bytes output (%g%%) in %s (%s / sec)",
|
|
input_bytes, input_bytes, data.size(), data.size(), size_ratio, time_str.c_str(), bytes_per_sec_str.c_str());
|
|
|
|
if (is_pr2 || is_prc) {
|
|
if (is_decompress && (data.size() != pr2_expected_size)) {
|
|
log_warning("Result data size (%zu bytes) does not match expected size from PR2 header (%zu bytes)", data.size(), pr2_expected_size);
|
|
} else if (!is_decompress) {
|
|
uint32_t pr2_seed = seed.empty() ? random_object<uint32_t>() : stoul(seed, nullptr, 16);
|
|
data = is_big_endian
|
|
? encrypt_pr2_data<true>(data, input_bytes, pr2_seed)
|
|
: encrypt_pr2_data<false>(data, input_bytes, pr2_seed);
|
|
}
|
|
}
|
|
|
|
const char* extension;
|
|
if (is_decompress) {
|
|
extension = "dec";
|
|
} else if (is_prs) {
|
|
extension = "prs";
|
|
} else if (is_bc0) {
|
|
extension = "bc0";
|
|
} else if (is_prc) {
|
|
extension = "prc";
|
|
} else if (is_pr2) {
|
|
extension = "pr2";
|
|
} else {
|
|
throw logic_error("unknown action");
|
|
}
|
|
write_output_data(args, data.data(), data.size(), extension);
|
|
}
|
|
|
|
Action a_compress_prs("compress-prs", nullptr, a_compress_decompress_fn);
|
|
Action a_compress_bc0("compress-bc0", nullptr, a_compress_decompress_fn);
|
|
Action a_compress_pr2("compress-pr2", nullptr, a_compress_decompress_fn);
|
|
Action a_compress_prc("compress-prc", "\
|
|
compress-prs [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
compress-pr2 [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
compress-prc [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
compress-bc0 [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Compress data using the PRS, PR2, PRC, or BC0 algorithms. By default, the\n\
|
|
heuristic-based compressor is used, which gives a good balance between\n\
|
|
memory usage, CPU usage, and output size. For PRS and PR2, this compressor\n\
|
|
can be tuned with the --compression-level=N option, which specifies how\n\
|
|
aggressive the compressor should be in searching for literal sequences. The\n\
|
|
default level is 0; a higher value generally means slower compression and a\n\
|
|
smaller output size. If the compression level is -1, the input data is\n\
|
|
encoded in a PRS-compatible format but not actually compressed, resulting\n\
|
|
in valid PRS data which is about 9/8 the size of the input.\n\
|
|
There is also a compressor which produces the absolute smallest output\n\
|
|
size, but uses much more memory and CPU time. To use this compressor, use\n\
|
|
the --optimal option.\n",
|
|
a_compress_decompress_fn);
|
|
Action a_decompress_prs("decompress-prs", nullptr, a_compress_decompress_fn);
|
|
Action a_decompress_bc0("decompress-bc0", nullptr, a_compress_decompress_fn);
|
|
Action a_decompress_pr2("decompress-pr2", nullptr, a_compress_decompress_fn);
|
|
Action a_decompress_prc("decompress-prc", "\
|
|
decompress-prs [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
decompress-pr2 [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
decompress-prc [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
decompress-bc0 [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Decompress data compressed using the PRS, PR2, PRC, or BC0 algorithms.\n",
|
|
a_compress_decompress_fn);
|
|
|
|
Action a_prs_size(
|
|
"prs-size", "\
|
|
prs-size [INPUT-FILENAME]\n\
|
|
Compute the decompressed size of the PRS-compressed input data, but don\'t\n\
|
|
write the decompressed data anywhere.\n",
|
|
+[](Arguments& args) {
|
|
string data = read_input_data(args);
|
|
size_t input_bytes = data.size();
|
|
size_t output_bytes = prs_decompress_size(data);
|
|
log_info("%zu (0x%zX) bytes input => %zu (0x%zX) bytes output",
|
|
input_bytes, input_bytes, output_bytes, output_bytes);
|
|
});
|
|
|
|
Action a_disassemble_prs(
|
|
"disassemble-prs", nullptr, +[](Arguments& args) {
|
|
prs_disassemble(stdout, read_input_data(args));
|
|
});
|
|
Action a_disassemble_bc0(
|
|
"disassemble-bc0", "\
|
|
disassemble-prs [INPUT-FILENAME]\n\
|
|
disassemble-bc0 [INPUT-FILENAME]\n\
|
|
Write a textual representation of the commands contained in a PRS or BC0\n\
|
|
command stream. The output is written to stdout. This is mainly useful for\n\
|
|
debugging the compressors and decompressors themselves.\n",
|
|
+[](Arguments& args) {
|
|
bc0_disassemble(stdout, read_input_data(args));
|
|
});
|
|
|
|
static void a_encrypt_decrypt_fn(Arguments& args) {
|
|
bool is_decrypt = (args.get<string>(0) == "decrypt-data");
|
|
string seed = args.get<string>("seed");
|
|
bool is_big_endian = args.get<bool>("big-endian");
|
|
auto version = get_cli_version(args);
|
|
|
|
shared_ptr<PSOEncryption> crypt;
|
|
switch (version) {
|
|
case Version::DC_NTE:
|
|
case Version::DC_V1_11_2000_PROTOTYPE:
|
|
case Version::DC_V1:
|
|
case Version::DC_V2:
|
|
case Version::PC_NTE:
|
|
case Version::PC_V2:
|
|
case Version::GC_NTE:
|
|
crypt = make_shared<PSOV2Encryption>(stoul(seed, nullptr, 16));
|
|
break;
|
|
case Version::GC_V3:
|
|
case Version::XB_V3:
|
|
case Version::GC_EP3_NTE:
|
|
case Version::GC_EP3:
|
|
crypt = make_shared<PSOV3Encryption>(stoul(seed, nullptr, 16));
|
|
break;
|
|
case Version::BB_V4: {
|
|
string key_name = args.get<string>("key");
|
|
if (key_name.empty()) {
|
|
throw runtime_error("the --key option is required for BB");
|
|
}
|
|
seed = parse_data_string(seed, nullptr, ParseDataFlags::ALLOW_FILES);
|
|
auto key = load_object_file<PSOBBEncryption::KeyFile>("system/blueburst/keys/" + key_name + ".nsk");
|
|
crypt = make_shared<PSOBBEncryption>(key, seed.data(), seed.size());
|
|
break;
|
|
}
|
|
default:
|
|
throw logic_error("invalid game version");
|
|
}
|
|
|
|
string data = read_input_data(args);
|
|
|
|
size_t original_size = data.size();
|
|
data.resize((data.size() + 7) & (~7), '\0');
|
|
|
|
if (is_big_endian) {
|
|
uint32_t* dwords = reinterpret_cast<uint32_t*>(data.data());
|
|
for (size_t x = 0; x < (data.size() >> 2); x++) {
|
|
dwords[x] = bswap32(dwords[x]);
|
|
}
|
|
}
|
|
|
|
if (is_decrypt) {
|
|
crypt->decrypt(data.data(), data.size());
|
|
} else {
|
|
crypt->encrypt(data.data(), data.size());
|
|
}
|
|
|
|
if (is_big_endian) {
|
|
uint32_t* dwords = reinterpret_cast<uint32_t*>(data.data());
|
|
for (size_t x = 0; x < (data.size() >> 2); x++) {
|
|
dwords[x] = bswap32(dwords[x]);
|
|
}
|
|
}
|
|
|
|
data.resize(original_size);
|
|
|
|
write_output_data(args, data.data(), data.size(), "dec");
|
|
}
|
|
|
|
Action a_encrypt_data("encrypt-data", nullptr, a_encrypt_decrypt_fn);
|
|
Action a_decrypt_data("decrypt-data", "\
|
|
encrypt-data [INPUT-FILENAME [OUTPUT-FILENAME] [OPTIONS...]]\n\
|
|
decrypt-data [INPUT-FILENAME [OUTPUT-FILENAME] [OPTIONS...]]\n\
|
|
Encrypt or decrypt data using PSO\'s standard network protocol encryption.\n\
|
|
By default, PSO V3 (GameCube/XBOX) encryption is used, but this can be\n\
|
|
overridden with the --pc or --bb options. The --seed=SEED option specifies\n\
|
|
the encryption seed (4 hex bytes for PC or GC, or 48 hex bytes for BB). For\n\
|
|
BB, the --key=KEY-NAME option is required as well, and refers to a .nsk\n\
|
|
file in system/blueburst/keys (without the directory or .nsk extension).\n\
|
|
For non-BB ciphers, the --big-endian option applies the cipher masks as\n\
|
|
big-endian instead of little-endian, which is necessary for some GameCube\n\
|
|
file formats.\n",
|
|
a_encrypt_decrypt_fn);
|
|
|
|
static void a_encrypt_decrypt_trivial_fn(Arguments& args) {
|
|
bool is_decrypt = (args.get<string>(0) == "decrypt-trivial-data");
|
|
string seed = args.get<string>("seed");
|
|
|
|
if (seed.empty() && !is_decrypt) {
|
|
throw logic_error("--seed is required when encrypting data");
|
|
}
|
|
string data = read_input_data(args);
|
|
uint8_t basis;
|
|
if (seed.empty()) {
|
|
uint8_t best_seed = 0x00;
|
|
size_t best_seed_score = 0;
|
|
for (size_t z = 0; z < 0x100; z++) {
|
|
string decrypted = data;
|
|
decrypt_trivial_gci_data(decrypted.data(), decrypted.size(), z);
|
|
size_t score = 0;
|
|
for (size_t x = 0; x < decrypted.size(); x++) {
|
|
if (decrypted[x] == '\0') {
|
|
score++;
|
|
}
|
|
}
|
|
if (score > best_seed_score) {
|
|
best_seed = z;
|
|
best_seed_score = score;
|
|
}
|
|
}
|
|
fprintf(stderr, "Basis appears to be %02hhX (%zu zero bytes in output)\n",
|
|
best_seed, best_seed_score);
|
|
basis = best_seed;
|
|
} else {
|
|
basis = stoul(seed, nullptr, 16);
|
|
}
|
|
decrypt_trivial_gci_data(data.data(), data.size(), basis);
|
|
write_output_data(args, data.data(), data.size(), "dec");
|
|
}
|
|
|
|
Action a_encrypt_trivial_data("encrypt-trivial-data", nullptr, a_encrypt_decrypt_trivial_fn);
|
|
Action a_decrypt_trivial_data("decrypt-trivial-data", "\
|
|
encrypt-trivial-data --seed=BASIS [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
decrypt-trivial-data [--seed=BASIS] [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Encrypt or decrypt data using the Episode 3 trivial algorithm. When\n\
|
|
encrypting, --seed=BASIS is required; BASIS should be a single byte\n\
|
|
specified in hexadecimal. When decrypting, BASIS should be specified the\n\
|
|
same way, but if it is not given, newserv will try all possible basis\n\
|
|
values and return the one that results in the greatest number of zero bytes\n\
|
|
in the output.\n",
|
|
a_encrypt_decrypt_trivial_fn);
|
|
|
|
Action a_decrypt_registry_value(
|
|
"decrypt-registry-value", nullptr, +[](Arguments& args) {
|
|
string data = read_input_data(args);
|
|
string out_data = decrypt_v2_registry_value(data.data(), data.size());
|
|
write_output_data(args, out_data.data(), out_data.size(), "dec");
|
|
});
|
|
|
|
Action a_encrypt_challenge_data(
|
|
"encrypt-challenge-data", nullptr, +[](Arguments& args) {
|
|
string data = read_input_data(args);
|
|
encrypt_challenge_rank_text_t<uint8_t>(data.data(), data.size());
|
|
write_output_data(args, data.data(), data.size(), "dec");
|
|
});
|
|
Action a_decrypt_challenge_data(
|
|
"decrypt-challenge-data", "\
|
|
encrypt-challenge-data [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
decrypt-challenge-data [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Encrypt or decrypt data using the challenge mode trivial algorithm.\n",
|
|
+[](Arguments& args) {
|
|
string data = read_input_data(args);
|
|
decrypt_challenge_rank_text_t<uint8_t>(data.data(), data.size());
|
|
write_output_data(args, data.data(), data.size(), "dec");
|
|
});
|
|
|
|
static void a_encrypt_decrypt_gci_save_fn(Arguments& args) {
|
|
bool is_decrypt = (args.get<string>(0) == "decrypt-gci-save");
|
|
bool skip_checksum = args.get<bool>("skip-checksum");
|
|
string seed = args.get<string>("seed");
|
|
string system_filename = args.get<string>("sys");
|
|
int64_t override_round2_seed = args.get<int64_t>("round2-seed", -1, Arguments::IntFormat::HEX);
|
|
|
|
uint32_t round1_seed;
|
|
if (!system_filename.empty()) {
|
|
string system_data = load_file(system_filename);
|
|
StringReader r(system_data);
|
|
const auto& header = r.get<PSOGCIFileHeader>();
|
|
header.check();
|
|
const auto& system = r.get<PSOGCSystemFile>();
|
|
round1_seed = system.creation_timestamp;
|
|
} else if (!seed.empty()) {
|
|
round1_seed = stoul(seed, nullptr, 16);
|
|
} else {
|
|
throw runtime_error("either --sys or --seed must be given");
|
|
}
|
|
|
|
auto data = read_input_data(args);
|
|
StringReader r(data);
|
|
const auto& header = r.get<PSOGCIFileHeader>();
|
|
header.check();
|
|
|
|
size_t data_start_offset = r.where();
|
|
|
|
auto process_file = [&]<typename StructT>() {
|
|
if (is_decrypt) {
|
|
const void* data_section = r.getv(header.data_size);
|
|
auto decrypted = decrypt_fixed_size_data_section_t<StructT, true>(
|
|
data_section, header.data_size, round1_seed, skip_checksum, override_round2_seed);
|
|
*reinterpret_cast<StructT*>(data.data() + data_start_offset) = decrypted;
|
|
} else {
|
|
const auto& s = r.get<StructT>();
|
|
auto encrypted = encrypt_fixed_size_data_section_t<StructT, true>(s, round1_seed);
|
|
if (data_start_offset + encrypted.size() > data.size()) {
|
|
throw runtime_error("encrypted result exceeds file size");
|
|
}
|
|
memcpy(data.data() + data_start_offset, encrypted.data(), encrypted.size());
|
|
}
|
|
};
|
|
|
|
if (header.data_size == sizeof(PSOGCGuildCardFile)) {
|
|
process_file.template operator()<PSOGCGuildCardFile>();
|
|
} else if (header.is_ep12() && (header.data_size == sizeof(PSOGCCharacterFile))) {
|
|
process_file.template operator()<PSOGCCharacterFile>();
|
|
} else if (header.is_ep3() && (header.data_size == sizeof(PSOGCEp3CharacterFile))) {
|
|
auto* charfile = reinterpret_cast<PSOGCEp3CharacterFile*>(data.data() + data_start_offset);
|
|
if (!is_decrypt) {
|
|
for (size_t z = 0; z < charfile->characters.size(); z++) {
|
|
charfile->characters[z].ep3_config.encrypt(random_object<uint8_t>());
|
|
}
|
|
}
|
|
process_file.template operator()<PSOGCEp3CharacterFile>();
|
|
if (is_decrypt) {
|
|
for (size_t z = 0; z < charfile->characters.size(); z++) {
|
|
charfile->characters[z].ep3_config.decrypt();
|
|
}
|
|
}
|
|
} else {
|
|
throw runtime_error("unrecognized save type");
|
|
}
|
|
|
|
write_output_data(args, data.data(), data.size(), is_decrypt ? "gcid" : "gci");
|
|
}
|
|
|
|
Action a_decrypt_gci_save("decrypt-gci-save", nullptr, a_encrypt_decrypt_gci_save_fn);
|
|
Action a_encrypt_gci_save("encrypt-gci-save", "\
|
|
encrypt-gci-save CRYPT-OPTION INPUT-FILENAME [OUTPUT-FILENAME]\n\
|
|
decrypt-gci-save CRYPT-OPTION INPUT-FILENAME [OUTPUT-FILENAME]\n\
|
|
Encrypt or decrypt a character or Guild Card file in GCI format. If\n\
|
|
encrypting, the checksum is also recomputed and stored in the encrypted\n\
|
|
file. CRYPT-OPTION is required; it can be either --sys=SYSTEM-FILENAME\n\
|
|
(specifying the name of the corresponding PSO_SYSTEM .gci file) or\n\
|
|
--seed=ROUND1-SEED (specified as a 32-bit hexadecimal number).\n",
|
|
a_encrypt_decrypt_gci_save_fn);
|
|
|
|
static void a_encrypt_decrypt_pc_save_fn(Arguments& args) {
|
|
bool is_decrypt = (args.get<string>(0) == "decrypt-pc-save");
|
|
bool skip_checksum = args.get<bool>("skip-checksum");
|
|
string seed = args.get<string>("seed");
|
|
int64_t override_round2_seed = args.get<int64_t>("round2-seed", -1, Arguments::IntFormat::HEX);
|
|
|
|
if (seed.empty()) {
|
|
throw runtime_error("--seed must be given to specify the serial number");
|
|
}
|
|
uint32_t round1_seed = stoul(seed, nullptr, 16);
|
|
|
|
auto data = read_input_data(args);
|
|
if (data.size() == sizeof(PSOPCGuildCardFile)) {
|
|
if (is_decrypt) {
|
|
data = decrypt_fixed_size_data_section_s<false>(
|
|
data.data(), offsetof(PSOPCGuildCardFile, end_padding), round1_seed, skip_checksum, override_round2_seed);
|
|
} else {
|
|
data = encrypt_fixed_size_data_section_s<false>(
|
|
data.data(), offsetof(PSOPCGuildCardFile, end_padding), round1_seed);
|
|
}
|
|
data.resize((sizeof(PSOPCGuildCardFile) + 0x1FF) & (~0x1FF), '\0');
|
|
} else if (data.size() == sizeof(PSOPCCharacterFile)) {
|
|
PSOPCCharacterFile* charfile = reinterpret_cast<PSOPCCharacterFile*>(data.data());
|
|
if (is_decrypt) {
|
|
for (size_t z = 0; z < charfile->entries.size(); z++) {
|
|
if (charfile->entries[z].present) {
|
|
try {
|
|
charfile->entries[z].character = decrypt_fixed_size_data_section_t<PSOPCCharacterFile::CharacterEntry::Character, false>(
|
|
&charfile->entries[z].character, sizeof(charfile->entries[z].character), round1_seed, skip_checksum, override_round2_seed);
|
|
} catch (const exception& e) {
|
|
fprintf(stderr, "warning: cannot decrypt character %zu: %s\n", z, e.what());
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
for (size_t z = 0; z < charfile->entries.size(); z++) {
|
|
if (charfile->entries[z].present) {
|
|
string encrypted = encrypt_fixed_size_data_section_t<PSOPCCharacterFile::CharacterEntry::Character, false>(
|
|
charfile->entries[z].character, round1_seed);
|
|
if (encrypted.size() != sizeof(PSOPCCharacterFile::CharacterEntry::Character)) {
|
|
throw logic_error("incorrect encrypted result size");
|
|
}
|
|
charfile->entries[z].character = *reinterpret_cast<const PSOPCCharacterFile::CharacterEntry::Character*>(encrypted.data());
|
|
}
|
|
}
|
|
}
|
|
} else if (data.size() == sizeof(PSOPCCreationTimeFile)) {
|
|
throw runtime_error("the PSO______FLS file is not encrypted; it is just random data");
|
|
} else if (data.size() == sizeof(PSOPCSystemFile)) {
|
|
throw runtime_error("the PSO______COM file is not encrypted");
|
|
} else {
|
|
throw runtime_error("unknown save file type");
|
|
}
|
|
|
|
write_output_data(args, data.data(), data.size(), "dec");
|
|
}
|
|
|
|
Action a_decrypt_pc_save("decrypt-pc-save", nullptr, a_encrypt_decrypt_pc_save_fn);
|
|
Action a_encrypt_pc_save("encrypt-pc-save", "\
|
|
encrypt-pc-save --seed=SEED [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
decrypt-pc-save --seed=SEED [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Encrypt or decrypt a PSO PC character file (PSO______SYS or PSO______SYD)\n\
|
|
or Guild Card file (PSO______GUD). SEED should be the serial number\n\
|
|
associated with the save file, as a 32-bit hexadecimal integer.\n",
|
|
a_encrypt_decrypt_pc_save_fn);
|
|
|
|
static void a_encrypt_decrypt_save_data_fn(Arguments& args) {
|
|
bool is_decrypt = (args.get<string>(0) == "decrypt-save-data");
|
|
bool skip_checksum = args.get<bool>("skip-checksum");
|
|
bool is_big_endian = args.get<bool>("big-endian");
|
|
string seed = args.get<string>("seed");
|
|
int64_t override_round2_seed = args.get<int64_t>("round2-seed", -1, Arguments::IntFormat::HEX);
|
|
size_t bytes = args.get<size_t>("bytes", 0);
|
|
|
|
if (seed.empty()) {
|
|
throw runtime_error("--seed must be given to specify the round1 seed");
|
|
}
|
|
uint32_t round1_seed = stoul(seed, nullptr, 16);
|
|
|
|
auto data = read_input_data(args);
|
|
StringReader r(data);
|
|
|
|
string output_data;
|
|
size_t effective_size = bytes ? min<size_t>(bytes, data.size()) : data.size();
|
|
if (is_decrypt) {
|
|
output_data = is_big_endian
|
|
? decrypt_fixed_size_data_section_s<true>(data.data(), effective_size, round1_seed, skip_checksum, override_round2_seed)
|
|
: decrypt_fixed_size_data_section_s<false>(data.data(), effective_size, round1_seed, skip_checksum, override_round2_seed);
|
|
} else {
|
|
output_data = is_big_endian
|
|
? encrypt_fixed_size_data_section_s<true>(data.data(), effective_size, round1_seed)
|
|
: encrypt_fixed_size_data_section_s<false>(data.data(), effective_size, round1_seed);
|
|
}
|
|
write_output_data(args, output_data.data(), output_data.size(), "dec");
|
|
}
|
|
|
|
// TODO: Write usage text for these actions
|
|
Action a_decrypt_save_data("decrypt-save-data", nullptr, a_encrypt_decrypt_save_data_fn);
|
|
Action a_encrypt_save_data("encrypt-save-data", nullptr, a_encrypt_decrypt_save_data_fn);
|
|
|
|
Action a_decode_gci_snapshot(
|
|
"decode-gci-snapshot", "\
|
|
decode-gci-snapshot [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Decode a PSO GC snapshot file into a Windows BMP image.\n",
|
|
+[](Arguments& args) {
|
|
auto data = read_input_data(args);
|
|
StringReader r(data);
|
|
const auto& header = r.get<PSOGCIFileHeader>();
|
|
try {
|
|
header.check();
|
|
} catch (const exception& e) {
|
|
log_warning("File header failed validation (%s)", e.what());
|
|
}
|
|
const auto& file = r.get<PSOGCSnapshotFile>();
|
|
if (!file.checksum_correct()) {
|
|
log_warning("File internal checksum is incorrect");
|
|
}
|
|
|
|
auto img = file.decode_image();
|
|
string saved = img.save(Image::Format::WINDOWS_BITMAP);
|
|
write_output_data(args, saved.data(), saved.size(), "bmp");
|
|
});
|
|
|
|
Action a_encode_gvm(
|
|
"encode-gvm", "\
|
|
encode-gvm [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Encode an image in BMP or PPM/PNM format into a GVM texture. The resulting\n\
|
|
GVM file can be used as an Episode 3 lobby banner.\n",
|
|
+[](Arguments& args) {
|
|
const string& input_filename = args.get<string>(1, false);
|
|
Image img;
|
|
if (!input_filename.empty() && (input_filename != "-")) {
|
|
img = Image(input_filename);
|
|
} else {
|
|
img = Image(stdin);
|
|
}
|
|
string encoded = encode_gvm(img, img.get_has_alpha() ? GVRDataFormat::RGB5A3 : GVRDataFormat::RGB565);
|
|
write_output_data(args, encoded.data(), encoded.size(), "gvm");
|
|
});
|
|
|
|
Action a_salvage_gci(
|
|
"salvage-gci", "\
|
|
salvage-gci INPUT-FILENAME [--round2] [CRYPT-OPTION] [--bytes=SIZE]\n\
|
|
Attempt to find either the round-1 or round-2 decryption seed for a\n\
|
|
corrupted GCI file. If --round2 is given, then CRYPT-OPTION must be given\n\
|
|
(and should specify either a valid system file or the round1 seed).\n",
|
|
+[](Arguments& args) {
|
|
bool round2 = args.get<bool>("round2");
|
|
string seed = args.get<string>("seed");
|
|
string system_filename = args.get<string>("sys");
|
|
size_t num_threads = args.get<size_t>("threads", 0);
|
|
size_t offset = args.get<size_t>("offset", 0);
|
|
size_t stride = args.get<size_t>("stride", 1);
|
|
size_t bytes = args.get<size_t>("bytes", 0);
|
|
|
|
uint64_t likely_round1_seed = 0xFFFFFFFFFFFFFFFF;
|
|
if (!system_filename.empty()) {
|
|
try {
|
|
string system_data = load_file(system_filename);
|
|
StringReader r(system_data);
|
|
const auto& header = r.get<PSOGCIFileHeader>();
|
|
header.check();
|
|
const auto& system = r.get<PSOGCSystemFile>();
|
|
likely_round1_seed = system.creation_timestamp;
|
|
log_info("System file appears to be in order; round1 seed is %08" PRIX64, likely_round1_seed);
|
|
} catch (const exception& e) {
|
|
log_warning("Cannot parse system file (%s); ignoring it", e.what());
|
|
}
|
|
} else if (!seed.empty()) {
|
|
likely_round1_seed = stoul(seed, nullptr, 16);
|
|
log_info("Specified round1 seed is %08" PRIX64, likely_round1_seed);
|
|
}
|
|
|
|
if (round2 && likely_round1_seed > 0x100000000) {
|
|
throw invalid_argument("cannot find round2 seed without known round1 seed");
|
|
}
|
|
|
|
auto data = read_input_data(args);
|
|
StringReader r(data);
|
|
const auto& header = r.get<PSOGCIFileHeader>();
|
|
header.check();
|
|
|
|
const void* data_section = r.getv(header.data_size);
|
|
|
|
auto process_file = [&]<typename StructT>() {
|
|
vector<multimap<size_t, uint32_t>> top_seeds_by_thread(
|
|
num_threads ? num_threads : thread::hardware_concurrency());
|
|
parallel_range<uint64_t>(
|
|
[&](uint64_t seed, size_t thread_num) -> bool {
|
|
size_t zero_count;
|
|
if (round2) {
|
|
string decrypted = decrypt_gci_fixed_size_data_section_for_salvage(
|
|
data_section, header.data_size, likely_round1_seed, seed, bytes);
|
|
zero_count = count_zeroes(
|
|
decrypted.data() + offset,
|
|
decrypted.size() - offset,
|
|
stride);
|
|
} else {
|
|
auto decrypted = decrypt_fixed_size_data_section_t<StructT, true>(
|
|
data_section,
|
|
header.data_size,
|
|
seed,
|
|
true);
|
|
zero_count = count_zeroes(
|
|
reinterpret_cast<const uint8_t*>(&decrypted) + offset,
|
|
sizeof(decrypted) - offset,
|
|
stride);
|
|
}
|
|
auto& top_seeds = top_seeds_by_thread[thread_num];
|
|
if (top_seeds.size() < 10 || (zero_count >= top_seeds.begin()->second)) {
|
|
top_seeds.emplace(zero_count, seed);
|
|
if (top_seeds.size() > 10) {
|
|
top_seeds.erase(top_seeds.begin());
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
0,
|
|
0x100000000,
|
|
num_threads);
|
|
|
|
multimap<size_t, uint32_t> top_seeds;
|
|
for (const auto& thread_top_seeds : top_seeds_by_thread) {
|
|
for (const auto& it : thread_top_seeds) {
|
|
top_seeds.emplace(it.first, it.second);
|
|
}
|
|
}
|
|
for (const auto& it : top_seeds) {
|
|
const char* sys_seed_str = (!round2 && (it.second == likely_round1_seed))
|
|
? " (this is the seed from the system file)"
|
|
: "";
|
|
log_info("Round %c seed %08" PRIX32 " resulted in %zu zero bytes%s",
|
|
round2 ? '2' : '1', it.second, it.first, sys_seed_str);
|
|
}
|
|
};
|
|
|
|
if (header.data_size == sizeof(PSOGCGuildCardFile)) {
|
|
process_file.template operator()<PSOGCGuildCardFile>();
|
|
} else if (header.is_ep12() && (header.data_size == sizeof(PSOGCCharacterFile))) {
|
|
process_file.template operator()<PSOGCCharacterFile>();
|
|
} else if (header.is_ep3() && (header.data_size == sizeof(PSOGCEp3CharacterFile))) {
|
|
process_file.template operator()<PSOGCEp3CharacterFile>();
|
|
} else {
|
|
throw runtime_error("unrecognized save type");
|
|
}
|
|
});
|
|
|
|
Action a_find_decryption_seed(
|
|
"find-decryption-seed", "\
|
|
find-decryption-seed OPTIONS...\n\
|
|
Perform a brute-force search for a decryption seed of the given data. The\n\
|
|
ciphertext is specified with the --encrypted=DATA option and the expected\n\
|
|
plaintext is specified with the --decrypted=DATA option. The plaintext may\n\
|
|
include unmatched bytes (specified with the Phosg parse_data_string ?\n\
|
|
operator), but overall it must be the same length as the ciphertext. By\n\
|
|
default, this option uses PSO V3 encryption, but this can be overridden\n\
|
|
with --pc. (BB encryption seeds are too long to be searched for with this\n\
|
|
function.) By default, the number of worker threads is equal to the number\n\
|
|
of CPU cores in the system, but this can be overridden with the\n\
|
|
--threads=NUM-THREADS option.\n",
|
|
+[](Arguments& args) {
|
|
const auto& plaintexts_ascii = args.get_multi<string>("decrypted");
|
|
const auto& ciphertext_ascii = args.get<string>("encrypted");
|
|
auto version = get_cli_version(args);
|
|
if (plaintexts_ascii.empty() || ciphertext_ascii.empty()) {
|
|
throw runtime_error("both --encrypted and --decrypted must be specified");
|
|
}
|
|
if (uses_v4_encryption(version)) {
|
|
throw runtime_error("--find-decryption-seed cannot be used for BB ciphers");
|
|
}
|
|
bool skip_little_endian = args.get<bool>("skip-little-endian");
|
|
bool skip_big_endian = args.get<bool>("skip-big-endian");
|
|
size_t num_threads = args.get<size_t>("threads", 0);
|
|
|
|
size_t max_plaintext_size = 0;
|
|
vector<pair<string, string>> plaintexts;
|
|
for (const auto& plaintext_ascii : plaintexts_ascii) {
|
|
string mask;
|
|
string data = parse_data_string(plaintext_ascii, &mask, ParseDataFlags::ALLOW_FILES);
|
|
if (data.size() != mask.size()) {
|
|
throw logic_error("plaintext and mask are not the same size");
|
|
}
|
|
max_plaintext_size = max<size_t>(max_plaintext_size, data.size());
|
|
plaintexts.emplace_back(std::move(data), std::move(mask));
|
|
}
|
|
string ciphertext = parse_data_string(ciphertext_ascii, nullptr, ParseDataFlags::ALLOW_FILES);
|
|
|
|
auto mask_match = +[](const void* a, const void* b, const void* m, size_t size) -> bool {
|
|
const uint8_t* a8 = reinterpret_cast<const uint8_t*>(a);
|
|
const uint8_t* b8 = reinterpret_cast<const uint8_t*>(b);
|
|
const uint8_t* m8 = reinterpret_cast<const uint8_t*>(m);
|
|
for (size_t z = 0; z < size; z++) {
|
|
if ((a8[z] & m8[z]) != (b8[z] & m8[z])) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
uint64_t seed = parallel_range<uint64_t>([&](uint64_t seed, size_t) -> bool {
|
|
string be_decrypt_buf = ciphertext.substr(0, max_plaintext_size);
|
|
string le_decrypt_buf = ciphertext.substr(0, max_plaintext_size);
|
|
if (uses_v3_encryption(version)) {
|
|
PSOV3Encryption(seed).encrypt_both_endian(
|
|
le_decrypt_buf.data(),
|
|
be_decrypt_buf.data(),
|
|
be_decrypt_buf.size());
|
|
} else {
|
|
PSOV2Encryption(seed).encrypt_both_endian(
|
|
le_decrypt_buf.data(),
|
|
be_decrypt_buf.data(),
|
|
be_decrypt_buf.size());
|
|
}
|
|
|
|
for (const auto& plaintext : plaintexts) {
|
|
if (!skip_little_endian) {
|
|
if (mask_match(le_decrypt_buf.data(), plaintext.first.data(), plaintext.second.data(), plaintext.second.size())) {
|
|
return true;
|
|
}
|
|
}
|
|
if (!skip_big_endian) {
|
|
if (mask_match(be_decrypt_buf.data(), plaintext.first.data(), plaintext.second.data(), plaintext.second.size())) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
0, 0x100000000, num_threads);
|
|
|
|
if (seed < 0x100000000) {
|
|
log_info("Found seed %08" PRIX64, seed);
|
|
} else {
|
|
log_error("No seed found");
|
|
}
|
|
});
|
|
|
|
Action a_decode_gci(
|
|
"decode-gci", nullptr, +[](Arguments& args) {
|
|
string input_filename = args.get<string>(1, false);
|
|
if (input_filename.empty() || (input_filename == "-")) {
|
|
throw invalid_argument("an input filename is required");
|
|
}
|
|
string seed = args.get<string>("seed");
|
|
size_t num_threads = args.get<size_t>("threads", 0);
|
|
bool skip_checksum = args.get<bool>("skip-checksum");
|
|
int64_t dec_seed = seed.empty() ? -1 : stoul(seed, nullptr, 16);
|
|
auto decoded = decode_gci_data(read_input_data(args), num_threads, dec_seed, skip_checksum);
|
|
save_file(input_filename + ".dec", decoded);
|
|
});
|
|
Action a_decode_vms(
|
|
"decode-vms", nullptr, +[](Arguments& args) {
|
|
string input_filename = args.get<string>(1, false);
|
|
if (input_filename.empty() || (input_filename == "-")) {
|
|
throw invalid_argument("an input filename is required");
|
|
}
|
|
string seed = args.get<string>("seed");
|
|
size_t num_threads = args.get<size_t>("threads", 0);
|
|
bool skip_checksum = args.get<bool>("skip-checksum");
|
|
int64_t dec_seed = seed.empty() ? -1 : stoul(seed, nullptr, 16);
|
|
auto decoded = decode_vms_data(read_input_data(args), num_threads, dec_seed, skip_checksum);
|
|
save_file(input_filename + ".dec", decoded);
|
|
});
|
|
Action a_decode_dlq(
|
|
"decode-dlq", nullptr, +[](Arguments& args) {
|
|
string input_filename = args.get<string>(1, false);
|
|
if (input_filename.empty() || (input_filename == "-")) {
|
|
throw invalid_argument("an input filename is required");
|
|
}
|
|
auto decoded = decode_dlq_data(read_input_data(args));
|
|
save_file(input_filename + ".dec", decoded);
|
|
});
|
|
Action a_decode_qst(
|
|
"decode-qst", "\
|
|
decode-gci INPUT-FILENAME [OPTIONS...]\n\
|
|
decode-vms INPUT-FILENAME [OPTIONS...]\n\
|
|
decode-dlq INPUT-FILENAME\n\
|
|
decode-qst INPUT-FILENAME\n\
|
|
Decode the input quest file into a compressed, unencrypted .bin or .dat\n\
|
|
file (or in the case of decode-qst, both a .bin and a .dat file).\n\
|
|
INPUT-FILENAME must be specified, but there is no OUTPUT-FILENAME; the\n\
|
|
output is written to INPUT-FILENAME.dec (or .bin, or .dat). If the output\n\
|
|
is a .dec file, you can rename it to .bin or .dat manually. DLQ and QST\n\
|
|
decoding are relatively simple operations, but GCI and VMS decoding can be\n\
|
|
computationally expensive if the file is encrypted and doesn\'t contain an\n\
|
|
embedded seed. If you know the player\'s serial number who generated the\n\
|
|
GCI or VMS file, use the --seed=SEED option and give the serial number (as\n\
|
|
a hex-encoded 32-bit integer). If you don\'t know the serial number,\n\
|
|
newserv will find it via a brute-force search, which will take a long time.\n",
|
|
+[](Arguments& args) {
|
|
string input_filename = args.get<string>(1, false);
|
|
if (input_filename.empty() || (input_filename == "-")) {
|
|
throw invalid_argument("an input filename is required");
|
|
}
|
|
auto files = decode_qst_data(read_input_data(args));
|
|
for (const auto& it : files) {
|
|
save_file(input_filename + "-" + it.first, it.second);
|
|
}
|
|
});
|
|
|
|
Action a_encode_qst(
|
|
"encode-qst", "\
|
|
encode-qst INPUT-FILENAME [OUTPUT-FILENAME] [OPTIONS...]\n\
|
|
Encode the input quest file (in .bin/.dat format) into a .qst file. If\n\
|
|
--download is given, generates a download .qst instead of an online .qst.\n\
|
|
Specify the quest\'s game version with one of the --dc-nte, --dc-v1,\n\
|
|
--dc-v2, --pc, --gc-nte, --gc, --gc-ep3, --xb, or --bb options.\n",
|
|
+[](Arguments& args) {
|
|
string input_filename = args.get<string>(1, false);
|
|
if (input_filename.empty() || (input_filename == "-")) {
|
|
throw invalid_argument("an input filename is required");
|
|
}
|
|
auto version = get_cli_version(args);
|
|
bool download = args.get<bool>("download");
|
|
|
|
string bin_filename = input_filename;
|
|
string dat_filename = ends_with(bin_filename, ".bin")
|
|
? (bin_filename.substr(0, bin_filename.size() - 3) + "dat")
|
|
: (bin_filename + ".dat");
|
|
string pvr_filename = ends_with(bin_filename, ".bin")
|
|
? (bin_filename.substr(0, bin_filename.size() - 3) + "pvr")
|
|
: (bin_filename + ".pvr");
|
|
auto bin_data = make_shared<string>(load_file(bin_filename));
|
|
auto dat_data = make_shared<string>(load_file(dat_filename));
|
|
shared_ptr<string> pvr_data;
|
|
try {
|
|
pvr_data = make_shared<string>(load_file(pvr_filename));
|
|
} catch (const cannot_open_file&) {
|
|
}
|
|
auto vq = make_shared<VersionedQuest>(0, 0, version, 0, bin_data, dat_data, pvr_data);
|
|
if (download) {
|
|
vq = vq->create_download_quest();
|
|
}
|
|
string qst_data = vq->encode_qst();
|
|
|
|
write_output_data(args, qst_data.data(), qst_data.size(), "qst");
|
|
});
|
|
|
|
Action a_disassemble_quest_script(
|
|
"disassemble-quest-script", "\
|
|
disassemble-quest-script [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Disassemble the input quest script (.bin file) into a text representation\n\
|
|
of the commands and metadata it contains. Specify the quest\'s game version\n\
|
|
with one of the --dc-nte, --dc-v1, --dc-v2, --pc, --gc-nte, --gc, --gc-ep3,\n\
|
|
--xb, or --bb options. If you intend to edit and reassemble the script, use\n\
|
|
the --reassembly option to add explicit label numbers and remove offsets\n\
|
|
and data in code sections.\n",
|
|
+[](Arguments& args) {
|
|
string data = read_input_data(args);
|
|
auto version = get_cli_version(args);
|
|
if (!args.get<bool>("decompressed")) {
|
|
data = prs_decompress(data);
|
|
}
|
|
uint8_t language = args.get<bool>("japanese") ? 0 : 1;
|
|
bool reassembly_mode = args.get<bool>("reassembly");
|
|
string result = disassemble_quest_script(data.data(), data.size(), version, language, reassembly_mode);
|
|
write_output_data(args, result.data(), result.size(), "txt");
|
|
});
|
|
Action a_disassemble_quest_map(
|
|
"disassemble-quest-map", "\
|
|
disassemble-quest-map [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Disassemble the input quest map (.dat file) into a text representation of\n\
|
|
the data it contains.\n",
|
|
+[](Arguments& args) {
|
|
string data = read_input_data(args);
|
|
if (!args.get<bool>("decompressed")) {
|
|
data = prs_decompress(data);
|
|
}
|
|
string result = Map::disassemble_quest_data(data.data(), data.size());
|
|
write_output_data(args, result.data(), result.size(), "txt");
|
|
});
|
|
Action a_disassemble_set_data_table(
|
|
"disassemble-set-data-table", "\
|
|
disassemble-set-data-table [INPUT-FILENAME]\n\
|
|
Show the contents of a SetDataTable.rel file. A version option is required.\n",
|
|
+[](Arguments& args) {
|
|
Version version = get_cli_version(args);
|
|
SetDataTable sdt(version, read_input_data(args));
|
|
string str = sdt.str();
|
|
write_output_data(args, str.data(), str.size(), "txt");
|
|
});
|
|
Action a_check_set_data_table(
|
|
"check-set-data-tables", nullptr, +[](Arguments&) {
|
|
auto s = make_shared<ServerState>();
|
|
s->load_patch_indexes(false);
|
|
s->load_set_data_tables(false);
|
|
static_game_data_log.min_level = LogLevel::DISABLED;
|
|
|
|
auto get_file_data = [&](Version version, const string& filename) -> shared_ptr<const string> {
|
|
try {
|
|
return s->load_map_file(version, filename);
|
|
} catch (const cannot_open_file&) {
|
|
return nullptr;
|
|
}
|
|
};
|
|
|
|
auto check_filenames = [&](Version version, const string& sdt_filename, const vector<string>& ns_filenames) -> string {
|
|
for (size_t z = 0; z < ns_filenames.size(); z++) {
|
|
const auto& ns_filename = ns_filenames[z];
|
|
auto data = get_file_data(version, ns_filename);
|
|
if (data) {
|
|
if (sdt_filename != ns_filename) {
|
|
string ns_filenames_str = join(ns_filenames, ", ");
|
|
return string_printf("SDT => %s, NS => [%s]", sdt_filename.c_str(), ns_filenames_str.c_str());
|
|
}
|
|
return "OK";
|
|
}
|
|
if (!data && (sdt_filename == ns_filename)) {
|
|
string ns_filenames_str = join(ns_filenames, ", ");
|
|
return string_printf("SDT => %s (missing)", sdt_filename.c_str());
|
|
}
|
|
}
|
|
if (ns_filenames.empty() && sdt_filename.empty()) {
|
|
return "OK (no files)";
|
|
} else if (ns_filenames.empty()) {
|
|
auto data = get_file_data(version, sdt_filename);
|
|
if (data) {
|
|
return string_printf("NS blank, SDT => %s", sdt_filename.c_str());
|
|
} else {
|
|
return string_printf("NS blank, SDT => %s (missing)", sdt_filename.c_str());
|
|
}
|
|
} else if (sdt_filename.empty()) {
|
|
string ns_filenames_str = join(ns_filenames, ", ");
|
|
return string_printf("SDT blank, NS => [%s] (all missing)", ns_filenames_str.c_str());
|
|
} else {
|
|
string ns_filenames_str = join(ns_filenames, ", ");
|
|
return string_printf("SDT => %s (missing), NS => [%s] (all missing)", sdt_filename.c_str(), ns_filenames_str.c_str());
|
|
}
|
|
};
|
|
|
|
size_t num_checks = 0;
|
|
size_t num_errors = 0;
|
|
auto check_table = [&](Version version) {
|
|
vector<Episode> episodes({Episode::EP1});
|
|
if (!is_v1_or_v2(version) || (version == Version::GC_NTE)) {
|
|
episodes.emplace_back(Episode::EP2);
|
|
if (is_v4(version)) {
|
|
episodes.emplace_back(Episode::EP4);
|
|
}
|
|
}
|
|
|
|
vector<GameMode> modes({GameMode::NORMAL});
|
|
if (!is_v1(version)) {
|
|
modes.emplace_back(GameMode::BATTLE);
|
|
modes.emplace_back(GameMode::CHALLENGE);
|
|
}
|
|
if (is_v4(version)) {
|
|
modes.emplace_back(GameMode::SOLO);
|
|
}
|
|
|
|
uint8_t max_difficulty = is_v1(version) ? 2 : 3;
|
|
|
|
for (Episode episode : episodes) {
|
|
for (GameMode mode : modes) {
|
|
for (uint8_t difficulty = 0; difficulty <= max_difficulty; difficulty++) {
|
|
auto sdt = s->set_data_table(version, episode, mode, difficulty);
|
|
auto ns_var_maxes = variation_maxes_deprecated(version, episode, (mode == GameMode::SOLO));
|
|
size_t num_floors;
|
|
if (episode == Episode::EP4) {
|
|
num_floors = 0x0B;
|
|
} else if (episode == Episode::EP2) {
|
|
num_floors = 0x10;
|
|
} else {
|
|
num_floors = 0x0F;
|
|
}
|
|
for (size_t floor = 0; floor < num_floors; floor++) {
|
|
auto sdt_var_avail = sdt->num_available_variations_for_floor(episode, floor);
|
|
auto sdt_var_maxes = sdt->num_free_roam_variations_for_floor(episode, mode == GameMode::SOLO, floor);
|
|
size_t sdt_var1_max_avail = sdt_var_avail.first - 1;
|
|
size_t sdt_var2_max_avail = sdt_var_avail.second - 1;
|
|
size_t sdt_var1_max = sdt_var_maxes.first - 1;
|
|
size_t sdt_var2_max = sdt_var_maxes.second - 1;
|
|
size_t ns_var1_max = ns_var_maxes[floor * 2];
|
|
size_t ns_var2_max = ns_var_maxes[floor * 2 + 1];
|
|
num_checks += 4;
|
|
if (sdt_var1_max > sdt_var1_max_avail) {
|
|
fprintf(stdout, "## %-8s %-10s %-10s %-10s %02zX VAR1:[SDT:%02zX SDTA:%02zX]\n",
|
|
name_for_enum(version),
|
|
name_for_episode(episode),
|
|
name_for_mode(mode),
|
|
name_for_difficulty(difficulty),
|
|
floor,
|
|
sdt_var1_max,
|
|
sdt_var1_max_avail);
|
|
num_errors++;
|
|
}
|
|
if (sdt_var2_max > sdt_var2_max_avail) {
|
|
fprintf(stdout, "## %-8s %-10s %-10s %-10s %02zX VAR2:[SDT:%02zX SDTA:%02zX]\n",
|
|
name_for_enum(version),
|
|
name_for_episode(episode),
|
|
name_for_mode(mode),
|
|
name_for_difficulty(difficulty),
|
|
floor,
|
|
sdt_var2_max,
|
|
sdt_var2_max_avail);
|
|
num_errors++;
|
|
}
|
|
if (sdt_var1_max < ns_var1_max) {
|
|
fprintf(stdout, "## %-8s %-10s %-10s %-10s %02zX VAR1:[SDT:%02zX NS:%02zX]\n",
|
|
name_for_enum(version),
|
|
name_for_episode(episode),
|
|
name_for_mode(mode),
|
|
name_for_difficulty(difficulty),
|
|
floor,
|
|
sdt_var1_max,
|
|
ns_var1_max);
|
|
num_errors++;
|
|
}
|
|
if (sdt_var2_max < ns_var2_max) {
|
|
fprintf(stdout, "## %-8s %-10s %-10s %-10s %02zX VAR2:[SDT:%02zX NS:%02zX]\n",
|
|
name_for_enum(version),
|
|
name_for_episode(episode),
|
|
name_for_mode(mode),
|
|
name_for_difficulty(difficulty),
|
|
floor,
|
|
sdt_var2_max,
|
|
ns_var2_max);
|
|
num_errors++;
|
|
}
|
|
for (size_t var1 = 0; var1 <= ns_var1_max; var1++) {
|
|
for (size_t var2 = 0; var2 <= ns_var2_max; var2++) {
|
|
auto sdt_enemy_filename = sdt->map_filename_for_variation(floor, var1, var2, episode, mode, true);
|
|
auto sdt_object_filename = sdt->map_filename_for_variation(floor, var1, var2, episode, mode, false);
|
|
auto ns_enemy_filenames = map_filenames_for_variation_deprecated(floor, var1, var2, version, episode, mode, true);
|
|
auto ns_object_filenames = map_filenames_for_variation_deprecated(floor, var1, var2, version, episode, mode, false);
|
|
string enemies_error = check_filenames(version, sdt_enemy_filename, ns_enemy_filenames);
|
|
string objects_error = check_filenames(version, sdt_object_filename, ns_object_filenames);
|
|
num_checks += 2;
|
|
num_errors += (enemies_error != "OK") + (objects_error != "OK");
|
|
fprintf(stdout, "%s %-8s %-10s %-10s %-10s %02zX %02zX %02zX E:[%s] O:[%s] E:%-30s O:%-30s\n",
|
|
((enemies_error != "OK") || (objects_error != "OK")) ? "##" : " ",
|
|
name_for_enum(version),
|
|
name_for_episode(episode),
|
|
name_for_mode(mode),
|
|
name_for_difficulty(difficulty),
|
|
floor,
|
|
var1,
|
|
var2,
|
|
enemies_error.c_str(),
|
|
objects_error.c_str(),
|
|
sdt_enemy_filename.c_str(),
|
|
sdt_object_filename.c_str());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
check_table(Version::DC_NTE);
|
|
check_table(Version::DC_V1_11_2000_PROTOTYPE);
|
|
check_table(Version::DC_V1);
|
|
check_table(Version::DC_V2);
|
|
check_table(Version::PC_NTE);
|
|
check_table(Version::PC_V2);
|
|
check_table(Version::GC_NTE);
|
|
check_table(Version::GC_V3);
|
|
check_table(Version::XB_V3);
|
|
check_table(Version::BB_V4);
|
|
fprintf(stdout, "%zu/%zu errors\n", num_errors, num_checks);
|
|
});
|
|
|
|
Action a_assemble_quest_script(
|
|
"assemble-quest-script", "\
|
|
assemble-quest-script [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Assemble the input quest script (.txt file) into a compressed .bin file\n\
|
|
usable as an online quest script. If --decompressed is given, produces an\n\
|
|
uncompressed .bind file instead.\n",
|
|
+[](Arguments& args) {
|
|
string text = read_input_data(args);
|
|
string result = assemble_quest_script(text);
|
|
bool compress = !args.get<bool>("decompressed");
|
|
if (compress) {
|
|
result = prs_compress_optimal(result);
|
|
}
|
|
write_output_data(args, result.data(), result.size(), compress ? "bin" : "bind");
|
|
});
|
|
|
|
Action a_assemble_all_patches(
|
|
"assemble-all-patches", "\
|
|
assemble-all-patches\n\
|
|
Assemble all patches in the system/client-functions directory, and produce\n\
|
|
two compiled .bin files for each patch (one unencrypted, for most PSO\n\
|
|
versions, and one encrypted, for PSO GC JP v1.4, JP Ep3, and Ep3 Trial\n\
|
|
Edition). The output files are saved in system/client-functions.\n",
|
|
+[](Arguments&) {
|
|
auto s = make_shared<ServerState>();
|
|
s->compile_functions(false);
|
|
|
|
auto process_code = +[](shared_ptr<const CompiledFunctionCode> code,
|
|
uint32_t checksum_addr,
|
|
uint32_t checksum_size,
|
|
uint32_t override_start_addr) -> void {
|
|
for (uint8_t encrypted = 0; encrypted < 2; encrypted++) {
|
|
StringWriter w;
|
|
string data = prepare_send_function_call_data(code, {}, "", checksum_addr, checksum_size, override_start_addr, encrypted);
|
|
w.put(PSOCommandHeaderDCV3{.command = 0xB2, .flag = code->index, .size = data.size() + 4});
|
|
w.write(data);
|
|
string out_path = code->source_path + (encrypted ? ".enc.bin" : ".std.bin");
|
|
save_file(out_path, w.str());
|
|
fprintf(stderr, "... %s\n", out_path.c_str());
|
|
}
|
|
};
|
|
|
|
for (const auto& it : s->function_code_index->name_and_specific_version_to_patch_function) {
|
|
process_code(it.second, 0, 0, 0);
|
|
}
|
|
try {
|
|
process_code(s->function_code_index->name_to_function.at("VersionDetectGC"), 0, 0, 0);
|
|
} catch (const out_of_range&) {
|
|
}
|
|
try {
|
|
process_code(s->function_code_index->name_to_function.at("VersionDetectXB"), 0, 0, 0);
|
|
} catch (const out_of_range&) {
|
|
}
|
|
try {
|
|
process_code(s->function_code_index->name_to_function.at("CacheClearFix-Phase1"), 0x80000000, 8, 0x7F2734EC);
|
|
} catch (const out_of_range&) {
|
|
}
|
|
try {
|
|
process_code(s->function_code_index->name_to_function.at("CacheClearFix-Phase2"), 0, 0, 0);
|
|
} catch (const out_of_range&) {
|
|
}
|
|
});
|
|
|
|
void a_extract_archive_fn(Arguments& args) {
|
|
string output_prefix = args.get<string>(2, false);
|
|
if (output_prefix == "-") {
|
|
throw invalid_argument("output prefix cannot be stdout");
|
|
} else if (output_prefix.empty()) {
|
|
output_prefix = args.get<string>(1, false);
|
|
if (output_prefix.empty() || (output_prefix == "-")) {
|
|
throw invalid_argument("an input filename must be given");
|
|
}
|
|
output_prefix += "_";
|
|
}
|
|
|
|
string data = read_input_data(args);
|
|
auto data_shared = make_shared<string>(std::move(data));
|
|
|
|
if (args.get<string>(0) == "extract-afs") {
|
|
AFSArchive arch(data_shared);
|
|
const auto& all_entries = arch.all_entries();
|
|
for (size_t z = 0; z < all_entries.size(); z++) {
|
|
auto e = arch.get(z);
|
|
string out_file = string_printf("%s-%zu", output_prefix.c_str(), z);
|
|
save_file(out_file.c_str(), e.first, e.second);
|
|
fprintf(stderr, "... %s\n", out_file.c_str());
|
|
}
|
|
} else if (args.get<string>(0) == "extract-gsl") {
|
|
GSLArchive arch(data_shared, args.get<bool>("big-endian"));
|
|
for (const auto& entry_it : arch.all_entries()) {
|
|
auto e = arch.get(entry_it.first);
|
|
string out_file = output_prefix + entry_it.first;
|
|
save_file(out_file.c_str(), e.first, e.second);
|
|
fprintf(stderr, "... %s\n", out_file.c_str());
|
|
}
|
|
} else if (args.get<string>(0) == "extract-bml") {
|
|
BMLArchive arch(data_shared, args.get<bool>("big-endian"));
|
|
for (const auto& entry_it : arch.all_entries()) {
|
|
{
|
|
auto e = arch.get(entry_it.first);
|
|
string data = prs_decompress(e.first, e.second);
|
|
string out_file = output_prefix + entry_it.first;
|
|
save_file(out_file, data);
|
|
fprintf(stderr, "... %s\n", out_file.c_str());
|
|
}
|
|
|
|
auto gvm_e = arch.get_gvm(entry_it.first);
|
|
if (gvm_e.second) {
|
|
string data = prs_decompress(gvm_e.first, gvm_e.second);
|
|
string out_file = output_prefix + entry_it.first + ".gvm";
|
|
save_file(out_file, data);
|
|
fprintf(stderr, "... %s\n", out_file.c_str());
|
|
}
|
|
}
|
|
} else {
|
|
throw logic_error("unimplemented archive type");
|
|
}
|
|
}
|
|
|
|
Action a_extract_afs("extract-afs", nullptr, a_extract_archive_fn);
|
|
Action a_extract_gsl("extract-gsl", nullptr, a_extract_archive_fn);
|
|
Action a_extract_bml("extract-bml", "\
|
|
extract-afs [INPUT-FILENAME] [--big-endian]\n\
|
|
extract-gsl [INPUT-FILENAME] [--big-endian]\n\
|
|
extract-bml [INPUT-FILENAME] [--big-endian]\n\
|
|
Extract all files from an AFS, GSL, or BML archive into the current\n\
|
|
directory. input-filename may be specified. If output-filename is\n\
|
|
specified, then it is treated as a prefix which is prepended to the\n\
|
|
filename of each file contained in the archive. If --big-endian is given,\n\
|
|
the archive header is read in GameCube format; otherwise it is read in\n\
|
|
PC/BB format.\n",
|
|
a_extract_archive_fn);
|
|
|
|
Action a_decode_sjis(
|
|
"decode-sjis", nullptr, +[](Arguments& args) {
|
|
string data = read_input_data(args);
|
|
string result = tt_sjis_to_utf8(data);
|
|
write_output_data(args, result.data(), result.size(), "txt");
|
|
});
|
|
|
|
Action a_decode_text_archive(
|
|
"decode-text-archive", "\
|
|
decode-text-archive [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Decode a text archive to JSON. --collections=NUM_COLLECTIONS is given,\n\
|
|
expects a fixed number of collections in the input. If --has-pr3 is given,\n\
|
|
expects the input not to have a REL footer.\n",
|
|
+[](Arguments& args) {
|
|
string data = read_input_data(args);
|
|
bool is_sjis = args.get<bool>("japanese");
|
|
|
|
unique_ptr<TextSet> ts;
|
|
size_t collection_count = args.get<size_t>("collections", 0);
|
|
if (collection_count) {
|
|
ts = make_unique<BinaryTextSet>(data, collection_count, !args.get<bool>("has-pr3"), is_sjis);
|
|
} else {
|
|
ts = make_unique<BinaryTextAndKeyboardsSet>(data, args.get<bool>("big-endian"), is_sjis);
|
|
}
|
|
JSON j = ts->json();
|
|
string out_data = j.serialize(JSON::SerializeOption::FORMAT | JSON::SerializeOption::ESCAPE_CONTROLS_ONLY);
|
|
write_output_data(args, out_data.data(), out_data.size(), "json");
|
|
});
|
|
Action a_encode_text_archive(
|
|
"encode-text-archive", "\
|
|
encode-text-archive [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Encode a text archive. Currently only supports GC and Xbox format.\n",
|
|
+[](Arguments& args) {
|
|
const string& input_filename = args.get<string>(1, false);
|
|
const string& output_filename = args.get<string>(2, false);
|
|
bool is_sjis = args.get<bool>("japanese");
|
|
|
|
auto json = JSON::parse(read_input_data(args));
|
|
BinaryTextAndKeyboardsSet a(json);
|
|
auto result = a.serialize(args.get<bool>("big-endian"), is_sjis);
|
|
if (output_filename.empty()) {
|
|
if (input_filename.empty() || (input_filename == "-")) {
|
|
throw runtime_error("encoded text archive cannot be written to stdout");
|
|
}
|
|
save_file(string_printf("%s.pr2", input_filename.c_str()), result.first);
|
|
save_file(string_printf("%s.pr3", input_filename.c_str()), result.second);
|
|
} else if (output_filename == "-") {
|
|
throw runtime_error("encoded text archive cannot be written to stdout");
|
|
} else {
|
|
string out_filename = output_filename;
|
|
if (ends_with(out_filename, ".pr2")) {
|
|
save_file(out_filename, result.first);
|
|
out_filename[out_filename.size() - 1] = '3';
|
|
save_file(out_filename, result.second);
|
|
} else {
|
|
save_file(out_filename + ".pr2", result.first);
|
|
save_file(out_filename + ".pr3", result.second);
|
|
}
|
|
}
|
|
});
|
|
|
|
Action a_decode_unicode_text_set(
|
|
"decode-unicode-text-set", nullptr, +[](Arguments& args) {
|
|
UnicodeTextSet uts(read_input_data(args));
|
|
JSON j = uts.json();
|
|
string out_data = j.serialize(JSON::SerializeOption::FORMAT | JSON::SerializeOption::ESCAPE_CONTROLS_ONLY);
|
|
write_output_data(args, out_data.data(), out_data.size(), "json");
|
|
});
|
|
Action a_encode_unicode_text_set(
|
|
"encode-unicode-text-set", "\
|
|
decode-unicode-text-set [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
encode-unicode-text-set [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Decode a Unicode text set (e.g. unitxt_e.prs) to JSON for easy editing, or\n\
|
|
encode a JSON file to a Unicode text set.\n",
|
|
+[](Arguments& args) {
|
|
UnicodeTextSet uts(JSON::parse(read_input_data(args)));
|
|
string encoded = uts.serialize();
|
|
write_output_data(args, encoded.data(), encoded.size(), "prs");
|
|
});
|
|
|
|
Action a_decode_word_select_set(
|
|
"decode-word-select-set", "\
|
|
decode-word-select-set [INPUT-FILENAME]\n\
|
|
Decode a Word Select data file and print all the tokens. A version option\n\
|
|
(e.g. --gc-ep3) is required. If the Word Select set is for PC or BB, the\n\
|
|
--unitxt option is also required, and must point to a unitxt file in prs\n\
|
|
or JSON format. For PC (V2), the unitxt_e.prs file should be used; for BB,\n\
|
|
the unitxt_ws_e.prs file should be used.\n",
|
|
+[](Arguments& args) {
|
|
auto version = get_cli_version(args);
|
|
|
|
string unitxt_filename = args.get<string>("unitxt");
|
|
const vector<string>* unitxt_collection;
|
|
if (!unitxt_filename.empty()) {
|
|
unique_ptr<UnicodeTextSet> uts;
|
|
if (ends_with(unitxt_filename, ".prs")) {
|
|
uts = make_unique<UnicodeTextSet>(load_file(unitxt_filename));
|
|
} else if (ends_with(unitxt_filename, ".json")) {
|
|
uts = make_unique<UnicodeTextSet>(JSON::parse(load_file(unitxt_filename)));
|
|
} else {
|
|
throw runtime_error("unitxt filename must end in .prs or .json");
|
|
}
|
|
unitxt_collection = &uts->get((version == Version::BB_V4) ? 0 : 35);
|
|
} else {
|
|
unitxt_collection = nullptr;
|
|
}
|
|
|
|
WordSelectSet ws(read_input_data(args), version, unitxt_collection, args.get<bool>("japanese"));
|
|
ws.print(stdout);
|
|
});
|
|
Action a_print_word_select_table(
|
|
"print-word-select-table", "\
|
|
print-word-select-table\n\
|
|
Print the Word Select token translation table. If a version option is\n\
|
|
given, prints the table sorted by token ID for that version. If no version\n\
|
|
option is given, prints the token table sorted by canonical name.\n",
|
|
+[](Arguments& args) {
|
|
auto s = make_shared<ServerState>();
|
|
s->load_patch_indexes(false);
|
|
s->load_text_index(false);
|
|
s->load_word_select_table(false);
|
|
Version v;
|
|
try {
|
|
v = get_cli_version(args);
|
|
} catch (const runtime_error&) {
|
|
v = Version::UNKNOWN;
|
|
}
|
|
if (v != Version::UNKNOWN) {
|
|
s->word_select_table->print_index(stdout, v);
|
|
} else {
|
|
s->word_select_table->print(stdout);
|
|
}
|
|
});
|
|
|
|
Action a_cat_client(
|
|
"cat-client", "\
|
|
cat-client ADDR:PORT\n\
|
|
Connect to the given server and simulate a PSO client. newserv will then\n\
|
|
print all the received commands to stdout, and forward any commands typed\n\
|
|
into stdin to the remote server. It is assumed that the input and output\n\
|
|
are terminals, so all commands are hex-encoded. The --patch, --dc, --pc,\n\
|
|
--gc, and --bb options can be used to select the command format and\n\
|
|
encryption. If --bb is used, the --key=KEY-NAME option is also required (as\n\
|
|
in decrypt-data above).\n",
|
|
+[](Arguments& args) {
|
|
auto version = get_cli_version(args);
|
|
shared_ptr<PSOBBEncryption::KeyFile> key;
|
|
if (uses_v4_encryption(version)) {
|
|
string key_file_name = args.get<string>("key");
|
|
if (key_file_name.empty()) {
|
|
throw runtime_error("a key filename is required for BB client emulation");
|
|
}
|
|
key = make_shared<PSOBBEncryption::KeyFile>(
|
|
load_object_file<PSOBBEncryption::KeyFile>("system/blueburst/keys/" + key_file_name + ".nsk"));
|
|
}
|
|
shared_ptr<struct event_base> base(event_base_new(), event_base_free);
|
|
auto cat_client_remote = make_sockaddr_storage(parse_netloc(args.get<string>(1))).first;
|
|
CatSession session(base, cat_client_remote, get_cli_version(args), key);
|
|
event_base_dispatch(base.get());
|
|
});
|
|
|
|
Action a_convert_rare_item_set(
|
|
"convert-rare-item-set", "\
|
|
convert-rare-item-set INPUT-FILENAME [OUTPUT-FILENAME]\n\
|
|
If OUTPUT-FILENAME is not given, print the contents of a rare item table in\n\
|
|
a human-readable format. Otherwise, convert the input rare item set to a\n\
|
|
different format and write it to OUTPUT-FILENAME. Both filenames must end\n\
|
|
in one of the following extensions:\n\
|
|
.json (newserv JSON rare item table)\n\
|
|
.gsl (PSO BB little-endian GSL archive)\n\
|
|
.gslb (PSO GC big-endian GSL archive)\n\
|
|
.afs (PSO V2 little-endian AFS archive)\n\
|
|
.rel (Schtserv rare table; cannot be used in output filename)\n",
|
|
+[](Arguments& args) {
|
|
auto version = get_cli_version(args);
|
|
|
|
auto s = make_shared<ServerState>();
|
|
s->load_patch_indexes(false);
|
|
s->load_text_index(false);
|
|
s->load_item_definitions(false);
|
|
s->load_item_name_indexes(false);
|
|
|
|
string input_filename = args.get<string>(1, false);
|
|
if (input_filename.empty() || (input_filename == "-")) {
|
|
throw runtime_error("input filename must be given");
|
|
}
|
|
|
|
auto data = make_shared<string>(read_input_data(args));
|
|
shared_ptr<RareItemSet> rs;
|
|
if (ends_with(input_filename, ".json")) {
|
|
rs = make_shared<RareItemSet>(JSON::parse(*data), s->item_name_index(version));
|
|
} else if (ends_with(input_filename, ".gsl")) {
|
|
rs = make_shared<RareItemSet>(GSLArchive(data, false), false);
|
|
} else if (ends_with(input_filename, ".gslb")) {
|
|
rs = make_shared<RareItemSet>(GSLArchive(data, true), true);
|
|
} else if (ends_with(input_filename, ".afs")) {
|
|
rs = make_shared<RareItemSet>(AFSArchive(data), is_v1(version));
|
|
} else if (ends_with(input_filename, ".rel")) {
|
|
rs = make_shared<RareItemSet>(*data, true);
|
|
} else {
|
|
throw runtime_error("cannot determine input format; use a filename ending with .json, .gsl, .gslb, .afs, or .rel");
|
|
}
|
|
|
|
string output_filename = args.get<string>(2, false);
|
|
if (output_filename.empty() || (output_filename == "-")) {
|
|
rs->print_all_collections(stdout, s->item_name_index(version));
|
|
} else if (ends_with(output_filename, ".json")) {
|
|
auto json = rs->json(s->item_name_index(version));
|
|
string data = json.serialize(JSON::SerializeOption::FORMAT | JSON::SerializeOption::HEX_INTEGERS | JSON::SerializeOption::SORT_DICT_KEYS);
|
|
write_output_data(args, data.data(), data.size(), nullptr);
|
|
} else if (ends_with(output_filename, ".gsl")) {
|
|
string data = rs->serialize_gsl(args.get<bool>("big-endian"));
|
|
write_output_data(args, data.data(), data.size(), nullptr);
|
|
} else if (ends_with(output_filename, ".gslb")) {
|
|
string data = rs->serialize_gsl(true);
|
|
write_output_data(args, data.data(), data.size(), nullptr);
|
|
} else if (ends_with(output_filename, ".afs")) {
|
|
bool is_v1 = ::is_v1(get_cli_version(args, Version::GC_V3));
|
|
string data = rs->serialize_afs(is_v1);
|
|
write_output_data(args, data.data(), data.size(), nullptr);
|
|
} else {
|
|
throw runtime_error("cannot determine output format; use a filename ending with .json, .gsl, .gslb, or .afs");
|
|
}
|
|
});
|
|
|
|
Action a_describe_item(
|
|
"describe-item", "\
|
|
describe-item DATA-OR-DESCRIPTION\n\
|
|
Describe an item. The argument may be the item\'s raw hex code or a textual\n\
|
|
description of the item. If the description contains spaces, it must be\n\
|
|
quoted, such as \"L&K14 COMBAT +10 0/10/15/0/35\".\n",
|
|
+[](Arguments& args) {
|
|
string description = args.get<string>(1);
|
|
auto version = get_cli_version(args);
|
|
|
|
auto s = make_shared<ServerState>();
|
|
s->load_patch_indexes(false);
|
|
s->load_text_index(false);
|
|
s->load_item_definitions(false);
|
|
s->load_item_name_indexes(false);
|
|
auto name_index = s->item_name_index(version);
|
|
|
|
ItemData item = name_index->parse_item_description(description);
|
|
|
|
string desc = name_index->describe_item(item);
|
|
log_info("Data (decoded): %02hhX%02hhX%02hhX%02hhX %02hhX%02hhX%02hhX%02hhX %02hhX%02hhX%02hhX%02hhX -------- %02hhX%02hhX%02hhX%02hhX",
|
|
item.data1[0], item.data1[1], item.data1[2], item.data1[3],
|
|
item.data1[4], item.data1[5], item.data1[6], item.data1[7],
|
|
item.data1[8], item.data1[9], item.data1[10], item.data1[11],
|
|
item.data2[0], item.data2[1], item.data2[2], item.data2[3]);
|
|
|
|
ItemData item_v2 = item;
|
|
item_v2.encode_for_version(Version::PC_V2, s->item_parameter_table_for_encode(Version::PC_V2));
|
|
ItemData item_v2_decoded = item_v2;
|
|
item_v2_decoded.decode_for_version(Version::PC_V2);
|
|
|
|
log_info("Data (V2-encoded): %02hhX%02hhX%02hhX%02hhX %02hhX%02hhX%02hhX%02hhX %02hhX%02hhX%02hhX%02hhX -------- %02hhX%02hhX%02hhX%02hhX",
|
|
item_v2.data1[0], item_v2.data1[1], item_v2.data1[2], item_v2.data1[3],
|
|
item_v2.data1[4], item_v2.data1[5], item_v2.data1[6], item_v2.data1[7],
|
|
item_v2.data1[8], item_v2.data1[9], item_v2.data1[10], item_v2.data1[11],
|
|
item_v2.data2[0], item_v2.data2[1], item_v2.data2[2], item_v2.data2[3]);
|
|
if (item_v2_decoded != item) {
|
|
log_warning("V2-decoded data does not match original data");
|
|
log_warning("Data (V2-decoded): %02hhX%02hhX%02hhX%02hhX %02hhX%02hhX%02hhX%02hhX %02hhX%02hhX%02hhX%02hhX -------- %02hhX%02hhX%02hhX%02hhX",
|
|
item_v2_decoded.data1[0], item_v2_decoded.data1[1], item_v2_decoded.data1[2], item_v2_decoded.data1[3],
|
|
item_v2_decoded.data1[4], item_v2_decoded.data1[5], item_v2_decoded.data1[6], item_v2_decoded.data1[7],
|
|
item_v2_decoded.data1[8], item_v2_decoded.data1[9], item_v2_decoded.data1[10], item_v2_decoded.data1[11],
|
|
item_v2_decoded.data2[0], item_v2_decoded.data2[1], item_v2_decoded.data2[2], item_v2_decoded.data2[3]);
|
|
}
|
|
|
|
ItemData item_gc = item;
|
|
item_gc.encode_for_version(Version::GC_V3, s->item_parameter_table_for_encode(Version::GC_V3));
|
|
ItemData item_gc_decoded = item_gc;
|
|
item_gc_decoded.decode_for_version(Version::GC_V3);
|
|
|
|
log_info("Data (GC-encoded): %02hhX%02hhX%02hhX%02hhX %02hhX%02hhX%02hhX%02hhX %02hhX%02hhX%02hhX%02hhX -------- %02hhX%02hhX%02hhX%02hhX",
|
|
item_gc.data1[0], item_gc.data1[1], item_gc.data1[2], item_gc.data1[3],
|
|
item_gc.data1[4], item_gc.data1[5], item_gc.data1[6], item_gc.data1[7],
|
|
item_gc.data1[8], item_gc.data1[9], item_gc.data1[10], item_gc.data1[11],
|
|
item_gc.data2[0], item_gc.data2[1], item_gc.data2[2], item_gc.data2[3]);
|
|
if (item_gc_decoded != item) {
|
|
log_warning("GC-decoded data does not match original data");
|
|
log_warning("Data (GC-decoded): %02hhX%02hhX%02hhX%02hhX %02hhX%02hhX%02hhX%02hhX %02hhX%02hhX%02hhX%02hhX -------- %02hhX%02hhX%02hhX%02hhX",
|
|
item_gc_decoded.data1[0], item_gc_decoded.data1[1], item_gc_decoded.data1[2], item_gc_decoded.data1[3],
|
|
item_gc_decoded.data1[4], item_gc_decoded.data1[5], item_gc_decoded.data1[6], item_gc_decoded.data1[7],
|
|
item_gc_decoded.data1[8], item_gc_decoded.data1[9], item_gc_decoded.data1[10], item_gc_decoded.data1[11],
|
|
item_gc_decoded.data2[0], item_gc_decoded.data2[1], item_gc_decoded.data2[2], item_gc_decoded.data2[3]);
|
|
}
|
|
|
|
log_info("Description: %s", desc.c_str());
|
|
|
|
size_t purchase_price = s->item_parameter_table(Version::BB_V4)->price_for_item(item);
|
|
size_t sale_price = purchase_price >> 3;
|
|
log_info("Purchase price: %zu; sale price: %zu", purchase_price, sale_price);
|
|
});
|
|
|
|
Action a_name_all_items(
|
|
"name-all-items", nullptr, +[](Arguments&) {
|
|
auto s = make_shared<ServerState>("system/config.json");
|
|
s->load_config_early();
|
|
s->load_patch_indexes(false);
|
|
s->load_text_index(false);
|
|
s->load_item_definitions(false);
|
|
s->load_item_name_indexes(false);
|
|
s->load_config_early();
|
|
|
|
set<uint32_t> all_primary_identifiers;
|
|
for (const auto& index : s->item_name_indexes) {
|
|
if (index) {
|
|
for (const auto& it : index->all_by_primary_identifier()) {
|
|
all_primary_identifiers.emplace(it.first);
|
|
}
|
|
}
|
|
}
|
|
|
|
fprintf(stderr, "IDENT :");
|
|
for (size_t v_s = 0; v_s < NUM_VERSIONS; v_s++) {
|
|
Version version = static_cast<Version>(v_s);
|
|
const auto& index = s->item_name_indexes.at(v_s);
|
|
if (index) {
|
|
fprintf(stderr, " %30s ", name_for_enum(version));
|
|
}
|
|
}
|
|
fputc('\n', stderr);
|
|
|
|
for (uint32_t primary_identifier : all_primary_identifiers) {
|
|
fprintf(stderr, "%08" PRIX32 ":", primary_identifier);
|
|
for (size_t v_s = 0; v_s < NUM_VERSIONS; v_s++) {
|
|
const auto& index = s->item_name_indexes.at(v_s);
|
|
if (index) {
|
|
Version version = static_cast<Version>(v_s);
|
|
auto pmt = s->item_parameter_table(version);
|
|
ItemData item = ItemData::from_primary_identifier(*s->item_stack_limits(version), primary_identifier);
|
|
string name = index->describe_item(item);
|
|
try {
|
|
bool is_rare = pmt->is_item_rare(item);
|
|
fprintf(stderr, " %30s%s", name.c_str(), is_rare ? " (*)" : " ");
|
|
} catch (const out_of_range&) {
|
|
fprintf(stderr, " ");
|
|
}
|
|
}
|
|
}
|
|
fputc('\n', stderr);
|
|
}
|
|
});
|
|
|
|
Action a_print_item_parameter_tables(
|
|
"print-item-tables", nullptr, +[](Arguments&) {
|
|
auto s = make_shared<ServerState>();
|
|
s->load_patch_indexes(false);
|
|
s->load_text_index(false);
|
|
s->load_item_definitions(false);
|
|
s->load_item_name_indexes(false);
|
|
for (size_t v_s = 0; v_s < NUM_VERSIONS; v_s++) {
|
|
const auto& index = s->item_name_indexes.at(v_s);
|
|
if (index) {
|
|
Version v = static_cast<Version>(v_s);
|
|
fprintf(stdout, "======== %s\n", name_for_enum(v));
|
|
index->print_table(stdout);
|
|
}
|
|
}
|
|
});
|
|
|
|
Action a_show_ep3_cards(
|
|
"show-ep3-cards", "\
|
|
show-ep3-cards\n\
|
|
Print the Episode 3 card definitions from the system/ep3 directory in a\n\
|
|
human-readable format.\n",
|
|
+[](Arguments& args) {
|
|
bool one_line = args.get<bool>("one-line");
|
|
|
|
auto s = make_shared<ServerState>();
|
|
s->load_ep3_cards(false);
|
|
|
|
unique_ptr<BinaryTextSet> text_english;
|
|
try {
|
|
JSON json = JSON::parse(load_file("system/ep3/text-english.json"));
|
|
text_english = make_unique<BinaryTextSet>(json);
|
|
} catch (const exception& e) {
|
|
}
|
|
|
|
auto card_ids = s->ep3_card_index->all_ids();
|
|
log_info("%zu card definitions", card_ids.size());
|
|
for (uint32_t card_id : card_ids) {
|
|
auto entry = s->ep3_card_index->definition_for_id(card_id);
|
|
string def_str = entry->def.str(one_line, text_english.get());
|
|
if (one_line) {
|
|
fprintf(stdout, "%s\n", def_str.c_str());
|
|
} else {
|
|
fprintf(stdout, "%s\n", def_str.c_str());
|
|
if (!entry->debug_tags.empty()) {
|
|
string tags = join(entry->debug_tags, ", ");
|
|
fprintf(stdout, " Tags: %s\n", tags.c_str());
|
|
}
|
|
if (!entry->dice_caption.empty()) {
|
|
fprintf(stdout, " Dice caption: %s\n", entry->dice_caption.c_str());
|
|
}
|
|
if (!entry->dice_caption.empty()) {
|
|
fprintf(stdout, " Dice text: %s\n", entry->dice_text.c_str());
|
|
}
|
|
if (!entry->text.empty()) {
|
|
string text = str_replace_all(entry->text, "\n", "\n ");
|
|
strip_trailing_whitespace(text);
|
|
fprintf(stdout, " Text:\n %s\n", text.c_str());
|
|
}
|
|
fputc('\n', stdout);
|
|
}
|
|
}
|
|
});
|
|
|
|
Action a_generate_ep3_cards_html(
|
|
"generate-ep3-cards-html", "\
|
|
generate-ep3-cards-html [--ep3-nte] [--threads=N]\n\
|
|
Generate an HTML file describing all Episode 3 card definitions from the\n\
|
|
system/ep3 directory. If --ep3-nte is given, use the Trial Edition card\n\
|
|
definitions instead.\n",
|
|
+[](Arguments& args) {
|
|
size_t num_threads = args.get<size_t>("threads", 0);
|
|
|
|
bool is_nte = (get_cli_version(args, Version::GC_EP3) == Version::GC_EP3_NTE);
|
|
|
|
auto s = make_shared<ServerState>();
|
|
s->load_patch_indexes(false);
|
|
s->load_text_index(false);
|
|
s->load_ep3_cards(false);
|
|
|
|
shared_ptr<const TextSet> text_english;
|
|
try {
|
|
text_english = s->text_index->get(Version::GC_EP3, 1);
|
|
} catch (const out_of_range&) {
|
|
}
|
|
|
|
struct CardInfo {
|
|
shared_ptr<const Episode3::CardIndex::CardEntry> ce;
|
|
string small_filename;
|
|
string medium_filename;
|
|
string large_filename;
|
|
string small_data_url;
|
|
string medium_data_url;
|
|
string large_data_url;
|
|
|
|
bool is_empty() const {
|
|
return (this->ce == nullptr) && this->small_data_url.empty() && this->medium_data_url.empty() && this->large_data_url.empty();
|
|
}
|
|
};
|
|
auto card_index = is_nte ? s->ep3_card_index_trial : s->ep3_card_index;
|
|
vector<CardInfo> infos;
|
|
for (uint32_t card_id : card_index->all_ids()) {
|
|
if (infos.size() <= card_id) {
|
|
infos.resize(card_id + 1);
|
|
}
|
|
infos[card_id].ce = card_index->definition_for_id(card_id);
|
|
}
|
|
bool show_large_column = false;
|
|
bool show_medium_column = false;
|
|
bool show_small_column = false;
|
|
for (const auto& filename : list_directory_sorted("system/ep3/cardtex")) {
|
|
if ((filename[0] == 'C' || filename[0] == 'M' || filename[0] == 'L') && (filename[1] == '_')) {
|
|
size_t card_id = stoull(filename.substr(2, 3), nullptr, 10);
|
|
if (infos.size() <= card_id) {
|
|
infos.resize(card_id + 1);
|
|
}
|
|
auto& info = infos[card_id];
|
|
if (filename[0] == 'C') {
|
|
info.large_filename = "system/ep3/cardtex/" + filename;
|
|
show_large_column = true;
|
|
} else if (filename[0] == 'L') {
|
|
info.medium_filename = "system/ep3/cardtex/" + filename;
|
|
show_medium_column = true;
|
|
} else if (filename[0] == 'M') {
|
|
info.small_filename = "system/ep3/cardtex/" + filename;
|
|
show_small_column = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
parallel_range<uint32_t>([&](uint32_t index, size_t) -> bool {
|
|
auto& info = infos[index];
|
|
if (!info.large_filename.empty()) {
|
|
Image img(info.large_filename);
|
|
Image cropped(512, 399);
|
|
cropped.blit(img, 0, 0, 512, 399, 0, 0);
|
|
info.large_data_url = cropped.png_data_url();
|
|
}
|
|
if (!info.medium_filename.empty()) {
|
|
Image img(info.medium_filename);
|
|
Image cropped(184, 144);
|
|
cropped.blit(img, 0, 0, 184, 144, 0, 0);
|
|
info.medium_data_url = cropped.png_data_url();
|
|
}
|
|
if (!info.small_filename.empty()) {
|
|
Image img(info.small_filename);
|
|
Image cropped(58, 43);
|
|
cropped.blit(img, 0, 0, 58, 43, 0, 0);
|
|
info.small_data_url = cropped.png_data_url();
|
|
}
|
|
return false;
|
|
},
|
|
0, infos.size(), num_threads);
|
|
|
|
deque<string> blocks;
|
|
blocks.emplace_back("<html><head><title>Phantasy Star Online Episode III cards</title></head><body style=\"background-color:#222222; color: #EEEEEE\">");
|
|
blocks.emplace_back("<table><tr><th style=\"text-align: left\">Legend:</th></tr><tr style=\"background-color: #663333\"><td>Card has no definition and is obviously incomplete</td></tr><tr style=\"background-color: #336633\"><td>Card is unobtainable in random draws but may be a quest or event reward</td></tr><tr style=\"background-color: #333333\"><td>Card is obtainable in random draws</td></tr></table><br /><br />");
|
|
blocks.emplace_back("<table><tr><th style=\"text-align: left; padding: 4px\">ID</th>");
|
|
if (show_small_column) {
|
|
blocks.emplace_back("<th style=\"text-align: left; padding: 4px\">Small</th>");
|
|
}
|
|
if (show_medium_column) {
|
|
blocks.emplace_back("<th style=\"text-align: left; padding: 4px\">Medium</th>");
|
|
}
|
|
if (show_large_column) {
|
|
blocks.emplace_back("<th style=\"text-align: left; padding: 4px\">Large</th>");
|
|
}
|
|
blocks.emplace_back("<th style=\"text-align: left; padding: 4px\">Text</th><th style=\"text-align: left; padding: 4px\">Disassembly</th></tr>");
|
|
for (size_t card_id = 0; card_id < infos.size(); card_id++) {
|
|
const auto& entry = infos[card_id];
|
|
if (entry.is_empty()) {
|
|
continue;
|
|
}
|
|
|
|
const char* background_color;
|
|
if (!entry.ce) {
|
|
background_color = "#663333";
|
|
} else if (entry.ce->def.cannot_drop ||
|
|
((entry.ce->def.rank == Episode3::CardRank::D1) || (entry.ce->def.rank == Episode3::CardRank::D2) || (entry.ce->def.rank == Episode3::CardRank::D3)) ||
|
|
((entry.ce->def.card_class() == Episode3::CardClass::BOSS_ATTACK_ACTION) || (entry.ce->def.card_class() == Episode3::CardClass::BOSS_TECH)) ||
|
|
((entry.ce->def.drop_rates[0] == 6) && (entry.ce->def.drop_rates[1] == 6))) {
|
|
background_color = "#336633";
|
|
} else {
|
|
background_color = "#333333";
|
|
}
|
|
|
|
blocks.emplace_back(string_printf("<tr style=\"background-color: %s\">", background_color));
|
|
blocks.emplace_back(string_printf("<td style=\"padding: 4px; vertical-align: top\"><pre>%04zX</pre></td>", card_id));
|
|
if (show_small_column) {
|
|
blocks.emplace_back("<td style=\"padding: 4px; vertical-align: top\">");
|
|
if (!entry.small_data_url.empty()) {
|
|
blocks.emplace_back("<img src=\"");
|
|
blocks.emplace_back(std::move(entry.small_data_url));
|
|
blocks.emplace_back("\" />");
|
|
}
|
|
blocks.emplace_back("</td>");
|
|
}
|
|
if (show_medium_column) {
|
|
blocks.emplace_back("<td style=\"padding: 4px; vertical-align: top\">");
|
|
if (!entry.medium_data_url.empty()) {
|
|
blocks.emplace_back("<img src=\"");
|
|
blocks.emplace_back(std::move(entry.medium_data_url));
|
|
blocks.emplace_back("\" />");
|
|
}
|
|
blocks.emplace_back("</td>");
|
|
}
|
|
if (show_large_column) {
|
|
blocks.emplace_back("<td style=\"padding: 4px; vertical-align: top\">");
|
|
if (!entry.large_data_url.empty()) {
|
|
blocks.emplace_back("<img src=\"");
|
|
blocks.emplace_back(std::move(entry.large_data_url));
|
|
blocks.emplace_back("\" />");
|
|
}
|
|
blocks.emplace_back("</td>");
|
|
}
|
|
blocks.emplace_back("<td style=\"padding: 4px; vertical-align: top\">");
|
|
if (entry.ce) {
|
|
blocks.emplace_back("<pre>");
|
|
blocks.emplace_back(entry.ce->text);
|
|
blocks.emplace_back("</pre></td><td style=\"padding: 4px; vertical-align: top\"><pre>");
|
|
blocks.emplace_back(entry.ce->def.str(false, text_english.get()));
|
|
blocks.emplace_back("</pre>");
|
|
} else {
|
|
blocks.emplace_back("</td><td style=\"padding: 4px; vertical-align: top\"><pre>Definition is missing</pre>");
|
|
}
|
|
blocks.emplace_back("</td></tr>");
|
|
}
|
|
blocks.emplace_back("</table></body></html>");
|
|
|
|
save_file("cards.html", join(blocks, ""));
|
|
});
|
|
|
|
Action a_show_ep3_maps(
|
|
"show-ep3-maps", "\
|
|
show-ep3-maps\n\
|
|
Print the Episode 3 maps from the system/ep3 directory in a (sort of)\n\
|
|
human-readable format.\n",
|
|
+[](Arguments&) {
|
|
config_log.info("Collecting Episode 3 data");
|
|
|
|
auto s = make_shared<ServerState>();
|
|
s->load_ep3_cards(false);
|
|
s->load_ep3_maps(false);
|
|
|
|
auto map_ids = s->ep3_map_index->all_numbers();
|
|
log_info("%zu maps", map_ids.size());
|
|
for (uint32_t map_id : map_ids) {
|
|
auto map = s->ep3_map_index->for_number(map_id);
|
|
const auto& vms = map->all_versions();
|
|
for (size_t language = 0; language < vms.size(); language++) {
|
|
if (!vms[language]) {
|
|
continue;
|
|
}
|
|
string map_s = vms[language]->map->str(s->ep3_card_index.get(), language);
|
|
fprintf(stdout, "(%c) %s\n", char_for_language_code(language), map_s.c_str());
|
|
}
|
|
}
|
|
});
|
|
|
|
Action a_show_battle_params(
|
|
"show-battle-params", "\
|
|
show-battle-params\n\
|
|
Print the Blue Burst battle parameters from the system/blueburst directory\n\
|
|
in a human-readable format.\n",
|
|
+[](Arguments&) {
|
|
auto s = make_shared<ServerState>();
|
|
s->load_patch_indexes(false);
|
|
s->load_battle_params(false);
|
|
|
|
fprintf(stdout, "Episode 1 multi\n");
|
|
s->battle_params->get_table(false, Episode::EP1).print(stdout);
|
|
fprintf(stdout, "Episode 1 solo\n");
|
|
s->battle_params->get_table(true, Episode::EP1).print(stdout);
|
|
fprintf(stdout, "Episode 2 multi\n");
|
|
s->battle_params->get_table(false, Episode::EP2).print(stdout);
|
|
fprintf(stdout, "Episode 2 solo\n");
|
|
s->battle_params->get_table(true, Episode::EP2).print(stdout);
|
|
fprintf(stdout, "Episode 4 multi\n");
|
|
s->battle_params->get_table(false, Episode::EP4).print(stdout);
|
|
fprintf(stdout, "Episode 4 solo\n");
|
|
s->battle_params->get_table(true, Episode::EP4).print(stdout);
|
|
});
|
|
|
|
Action a_find_rare_enemy_seeds(
|
|
"find-rare-enemy-seeds", "\
|
|
find-rare-enemy-seeds OPTIONS...\n\
|
|
Search all possible rare seeds to find those that produce one or more rare\n\
|
|
enemies in any set of variations. A version option (e.g. --gc) is required;\n\
|
|
an episode option (--ep1, --ep2, or --ep4) is also required. A difficulty\n\
|
|
option (--normal, --hard, --very-hard, or --ultimate) may be given; this\n\
|
|
affects which rare rates from config.json are used if --bb was given.\n\
|
|
Similarly, --battle, --challenge, or --solo may also be given; this affects\n\
|
|
which variations are used on all versions and which rare rates to use for\n\
|
|
BB. --threads=COUNT controls the number of threads to use for the search\n\
|
|
by default, one thread per CPU core is used. --min-count specifies how many\n\
|
|
rare enemies must be found to output the seed. Finally, --quest=NAME may be\n\
|
|
given to use that quest\'s map instead of the free-roam maps.\n",
|
|
+[](Arguments& args) {
|
|
auto version = get_cli_version(args);
|
|
auto episode = get_cli_episode(args);
|
|
auto difficulty = get_cli_difficulty(args);
|
|
auto mode = get_cli_game_mode(args);
|
|
size_t num_threads = args.get<size_t>("threads", 0);
|
|
size_t min_count = args.get<size_t>("min-count", 1);
|
|
string quest_name = args.get<string>("quest", false);
|
|
|
|
auto s = make_shared<ServerState>("system/config.json");
|
|
shared_ptr<const VersionedQuest> vq;
|
|
if (!quest_name.empty()) {
|
|
s->load_config_early();
|
|
s->load_quest_index(false);
|
|
auto q = s->quest_index(version)->get(quest_name);
|
|
if (!q) {
|
|
throw runtime_error("quest does not exist");
|
|
}
|
|
vq = q->version(version, 1);
|
|
if (!vq) {
|
|
throw runtime_error("quest version does not exist");
|
|
}
|
|
} else if (version == Version::BB_V4) {
|
|
s->load_config_early();
|
|
} else if (version == Version::PC_V2) {
|
|
s->load_patch_indexes(false);
|
|
} else {
|
|
s->clear_map_file_caches();
|
|
}
|
|
|
|
shared_ptr<const Map::RareEnemyRates> rare_rates;
|
|
if (version != Version::BB_V4) {
|
|
rare_rates = Map::DEFAULT_RARE_ENEMIES;
|
|
} else if (mode == GameMode::CHALLENGE) {
|
|
rare_rates = s->rare_enemy_rates_challenge;
|
|
} else {
|
|
rare_rates = s->rare_enemy_rates_by_difficulty[difficulty];
|
|
}
|
|
|
|
mutex output_lock;
|
|
auto thread_fn = [&](uint64_t seed, size_t) -> bool {
|
|
auto random_crypt = make_shared<PSOV2Encryption>(seed);
|
|
parray<le_uint32_t, 0x20> variations;
|
|
|
|
shared_ptr<Map> map;
|
|
if (vq) {
|
|
if (!vq->dat_contents_decompressed) {
|
|
throw runtime_error("quest does not have DAT data");
|
|
}
|
|
map = Lobby::load_maps(
|
|
version, episode, difficulty, 0, 0, rare_rates, seed, random_crypt, vq->dat_contents_decompressed);
|
|
|
|
} else {
|
|
generate_variations_deprecated(variations, random_crypt, version, episode, (mode == GameMode::SOLO));
|
|
map = Lobby::load_maps(
|
|
version,
|
|
episode,
|
|
mode,
|
|
difficulty,
|
|
0,
|
|
0,
|
|
s->set_data_table(version, episode, mode, difficulty),
|
|
bind(&ServerState::load_map_file, s.get(), placeholders::_1, placeholders::_2),
|
|
rare_rates,
|
|
seed,
|
|
random_crypt,
|
|
variations);
|
|
}
|
|
|
|
vector<size_t> rare_indexes;
|
|
for (size_t z = 0; z < map->enemies.size(); z++) {
|
|
if (enemy_type_is_rare(map->enemies[z].type)) {
|
|
rare_indexes.emplace_back(z);
|
|
}
|
|
}
|
|
|
|
if (rare_indexes.size() >= min_count) {
|
|
lock_guard g(output_lock);
|
|
fprintf(stdout, "%08" PRIX64 ":", seed);
|
|
for (size_t index : rare_indexes) {
|
|
fprintf(stdout, " E-%zX:%s", index, name_for_enum(map->enemies[index].type));
|
|
}
|
|
fprintf(stdout, "\n");
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
parallel_range<uint64_t>(thread_fn, 0, 0x100000000, num_threads, nullptr);
|
|
});
|
|
|
|
Action a_parse_object_graph(
|
|
"parse-object-graph", nullptr, +[](Arguments& args) {
|
|
uint32_t root_object_address = args.get<uint32_t>("root", Arguments::IntFormat::HEX);
|
|
string data = read_input_data(args);
|
|
PSOGCObjectGraph g(data, root_object_address);
|
|
g.print(stdout);
|
|
});
|
|
|
|
Action a_generate_dc_serial_number(
|
|
"generate-dc-serial-number", "\
|
|
generate-dc-serial-number DOMAIN SUBDOMAIN\n\
|
|
Generate a PSO DC serial number. DOMAIN should be 0 for Japanese, 1 for\n\
|
|
USA, or 2 for Europe. SUBDOMAIN should be 0 for v1, or 1 for v2.\n",
|
|
+[](Arguments& args) {
|
|
uint8_t domain = args.get<uint8_t>(1);
|
|
uint8_t subdomain = args.get<uint8_t>(2);
|
|
string serial_number = generate_dc_serial_number(domain, subdomain);
|
|
fprintf(stdout, "%s\n", serial_number.c_str());
|
|
});
|
|
Action a_generate_all_dc_serial_numbers(
|
|
"generate-all-dc-serial-numbers", "\
|
|
generate-all-dc-serial-numbers\n\
|
|
Generate all possible PSO DC serial numbers.\n",
|
|
+[](Arguments& args) {
|
|
size_t num_threads = args.get<size_t>("threads", 0);
|
|
|
|
auto serial_numbers = generate_all_dc_serial_numbers();
|
|
fprintf(stdout, "%zu (0x%zX) serial numbers found\n", serial_numbers.size(), serial_numbers.size());
|
|
for (const auto& it : serial_numbers) {
|
|
fprintf(stdout, "Valid serial number: %08" PRIX32, it.first);
|
|
for (uint8_t where : it.second) {
|
|
fprintf(stdout, " (domain=%hhu, subdomain=%hhu)",
|
|
static_cast<uint8_t>((where >> 2) & 3),
|
|
static_cast<uint8_t>(where & 3));
|
|
}
|
|
fputc('\n', stdout);
|
|
}
|
|
|
|
atomic<uint64_t> num_valid_serial_numbers = 0;
|
|
mutex output_lock;
|
|
auto thread_fn = [&](uint64_t serial_number, size_t) -> bool {
|
|
for (uint8_t domain = 0; domain < 3; domain++) {
|
|
for (uint8_t subdomain = 0; subdomain < 3; subdomain++) {
|
|
if (dc_serial_number_is_valid_fast(serial_number, domain, subdomain)) {
|
|
num_valid_serial_numbers++;
|
|
lock_guard g(output_lock);
|
|
fprintf(stdout, "Valid serial number: %08" PRIX64 " (domain=%hhu, subdomain=%hhu)\n", serial_number, domain, subdomain);
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
auto progress_fn = [&](uint64_t, uint64_t, uint64_t current_value, uint64_t) -> void {
|
|
uint64_t num_found = num_valid_serial_numbers.load();
|
|
fprintf(stderr, "... %08" PRIX64 " %" PRId64 " (0x%" PRIX64 ") found\r",
|
|
current_value, num_found, num_found);
|
|
};
|
|
parallel_range<uint64_t>(thread_fn, 0, 0x100000000, num_threads, progress_fn);
|
|
});
|
|
Action a_inspect_dc_serial_number(
|
|
"inspect-dc-serial-number", "\
|
|
inspect-dc-serial-number SERIAL-NUMBER\n\
|
|
Show which domain and subdomain the serial number belongs to. (As with\n\
|
|
generate-dc-serial-number, described above, this will tell you which PSO\n\
|
|
version it is valid for.)\n",
|
|
+[](Arguments& args) {
|
|
const string& serial_number_str = args.get<string>(1, false);
|
|
if (serial_number_str.empty()) {
|
|
throw invalid_argument("no serial number given");
|
|
}
|
|
size_t num_valid_subdomains = 0;
|
|
for (uint8_t domain = 0; domain < 3; domain++) {
|
|
for (uint8_t subdomain = 0; subdomain < 3; subdomain++) {
|
|
if (dc_serial_number_is_valid_fast(serial_number_str, domain, subdomain)) {
|
|
fprintf(stdout, "%s is valid in domain %hhu subdomain %hhu\n", serial_number_str.c_str(), domain, subdomain);
|
|
num_valid_subdomains++;
|
|
}
|
|
}
|
|
}
|
|
if (num_valid_subdomains == 0) {
|
|
fprintf(stdout, "%s is not valid in any domain\n", serial_number_str.c_str());
|
|
}
|
|
});
|
|
Action a_dc_serial_number_speed_test(
|
|
"dc-serial-number-speed-test", "\
|
|
dc-serial-number-speed-test\n\
|
|
Run a speed test of the two DC serial number validation functions.\n",
|
|
+[](Arguments& args) {
|
|
const string& seed = args.get<string>("seed");
|
|
if (seed.empty()) {
|
|
dc_serial_number_speed_test();
|
|
} else {
|
|
dc_serial_number_speed_test(stoul(seed, nullptr, 16));
|
|
}
|
|
});
|
|
|
|
Action a_ar_code_translator(
|
|
"ar-code-translator", nullptr, +[](Arguments& args) {
|
|
const string& dir = args.get<string>(1, false);
|
|
if (dir.empty() || (dir == "-")) {
|
|
throw invalid_argument("a directory name is required");
|
|
}
|
|
run_ar_code_translator(dir, args.get<string>(2, false), args.get<string>(3, false));
|
|
});
|
|
Action a_xbe_patch_translator(
|
|
"xbe-patch-translator", nullptr, +[](Arguments& args) {
|
|
const string& dir = args.get<string>(1, false);
|
|
if (dir.empty() || (dir == "-")) {
|
|
throw invalid_argument("a directory name is required");
|
|
}
|
|
run_xbe_patch_translator(dir, args.get<string>(2, false), args.get<string>(3, false));
|
|
});
|
|
|
|
Action a_diff_dol_files(
|
|
"diff-dol-files", nullptr, +[](Arguments& args) {
|
|
const string& a_filename = args.get<string>(1);
|
|
const string& b_filename = args.get<string>(2);
|
|
auto result = diff_dol_files(a_filename, b_filename);
|
|
for (const auto& it : result) {
|
|
string data = format_data_string(it.second, nullptr, FormatDataFlags::HEX_ONLY);
|
|
fprintf(stdout, "%08" PRIX32 " %s\n", it.first, data.c_str());
|
|
}
|
|
});
|
|
|
|
Action a_replay_ep3_battle_commands(
|
|
"replay-ep3-battle-commands", nullptr, +[](Arguments& args) {
|
|
auto s = make_shared<ServerState>();
|
|
s->load_ep3_cards(false);
|
|
s->load_ep3_maps(false);
|
|
|
|
int64_t base_seed = args.get<int64_t>("seed", -1);
|
|
bool is_trial = (get_cli_version(args, Version::GC_EP3) == Version::GC_EP3_NTE);
|
|
|
|
auto input = read_input_data(args);
|
|
vector<string> commands;
|
|
for (const auto& line : split(input, '\n')) {
|
|
string data = parse_data_string(line);
|
|
if (!data.empty()) {
|
|
commands.emplace_back(std::move(data));
|
|
}
|
|
}
|
|
|
|
auto run_replay = [&](int64_t seed, size_t) {
|
|
Episode3::Server::Options options = {
|
|
.card_index = s->ep3_card_index,
|
|
.map_index = s->ep3_map_index,
|
|
.behavior_flags = 0x0092,
|
|
.opt_rand_crypt = (seed >= 0) ? make_shared<PSOV2Encryption>(seed) : nullptr,
|
|
.tournament = nullptr,
|
|
.trap_card_ids = {},
|
|
};
|
|
if (is_trial) {
|
|
options.behavior_flags |= Episode3::BehaviorFlag::IS_TRIAL_EDITION;
|
|
}
|
|
if (base_seed >= 0) {
|
|
options.behavior_flags |= Episode3::BehaviorFlag::LOG_COMMANDS_IF_LOBBY_MISSING;
|
|
}
|
|
auto server = make_shared<Episode3::Server>(nullptr, std::move(options));
|
|
server->init();
|
|
for (const auto& command : commands) {
|
|
server->on_server_data_input(nullptr, command);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (base_seed >= 0) {
|
|
run_replay(base_seed, 0);
|
|
} else {
|
|
size_t num_threads = args.get<size_t>("threads", 0);
|
|
parallel_range<int64_t>(run_replay, 0, 0x100000000, num_threads);
|
|
}
|
|
});
|
|
|
|
Action a_run_server_replay_log(
|
|
"", nullptr, +[](Arguments& args) {
|
|
{
|
|
string build_date = format_time(BUILD_TIMESTAMP);
|
|
config_log.info("newserv %s compiled at %s", GIT_REVISION_HASH, build_date.c_str());
|
|
}
|
|
|
|
#ifdef PHOSG_WINDOWS
|
|
int evthread_ret = evthread_use_windows_threads();
|
|
#else
|
|
int evthread_ret = evthread_use_pthreads();
|
|
#endif
|
|
if (evthread_ret) {
|
|
throw runtime_error("failed to setup libevent threads");
|
|
}
|
|
|
|
if (!isdir("system/players")) {
|
|
config_log.info("Players directory does not exist; creating it");
|
|
mkdir("system/players", 0755);
|
|
}
|
|
|
|
string config_filename = args.get<string>("config");
|
|
const string& replay_log_filename = args.get<string>("replay-log");
|
|
bool is_replay = !replay_log_filename.empty();
|
|
if (config_filename.empty()) {
|
|
config_filename = "system/config.json";
|
|
}
|
|
|
|
signal(SIGPIPE, SIG_IGN);
|
|
if (isatty(fileno(stderr))) {
|
|
use_terminal_colors = true;
|
|
}
|
|
|
|
if (is_replay) {
|
|
set_function_compiler_available(false);
|
|
}
|
|
|
|
shared_ptr<struct event_base> base(event_base_new(), event_base_free);
|
|
auto state = make_shared<ServerState>(base, config_filename, is_replay);
|
|
state->load_all();
|
|
|
|
shared_ptr<DNSServer> dns_server;
|
|
if (state->dns_server_port && !is_replay) {
|
|
if (!state->dns_server_addr.empty()) {
|
|
config_log.info("Starting DNS server on %s:%hu", state->dns_server_addr.c_str(), state->dns_server_port);
|
|
} else {
|
|
config_log.info("Starting DNS server on port %hu", state->dns_server_port);
|
|
}
|
|
dns_server = make_shared<DNSServer>(base, state->local_address, state->external_address);
|
|
dns_server->listen(state->dns_server_addr, state->dns_server_port);
|
|
} else {
|
|
config_log.info("DNS server is disabled");
|
|
}
|
|
|
|
shared_ptr<ServerShell> shell;
|
|
shared_ptr<ReplaySession> replay_session;
|
|
shared_ptr<IPStackSimulator> ip_stack_simulator;
|
|
shared_ptr<HTTPServer> http_server;
|
|
if (is_replay) {
|
|
config_log.info("Starting proxy server");
|
|
state->proxy_server = make_shared<ProxyServer>(base, state);
|
|
config_log.info("Starting game server");
|
|
state->game_server = make_shared<Server>(base, state);
|
|
|
|
auto nop_destructor = +[](FILE*) {};
|
|
shared_ptr<FILE> log_f(stdin, nop_destructor);
|
|
if (replay_log_filename != "-") {
|
|
log_f = fopen_shared(replay_log_filename, "rt");
|
|
}
|
|
|
|
replay_session = make_shared<ReplaySession>(base, log_f.get(), state, args.get<bool>("require-basic-credentials"));
|
|
replay_session->start();
|
|
|
|
} else {
|
|
config_log.info("Opening sockets");
|
|
for (const auto& it : state->name_to_port_config) {
|
|
const auto& pc = it.second;
|
|
if (pc->behavior == ServerBehavior::PROXY_SERVER) {
|
|
if (!state->proxy_server.get()) {
|
|
config_log.info("Starting proxy server");
|
|
state->proxy_server = make_shared<ProxyServer>(base, state);
|
|
}
|
|
if (state->proxy_server.get()) {
|
|
// For PC and GC, proxy sessions are dynamically created when a client
|
|
// picks a destination from the menu. For patch and BB clients, there's
|
|
// no way to ask the client which destination they want, so only one
|
|
// destination is supported, and we have to manually specify the
|
|
// destination netloc here.
|
|
if (is_patch(pc->version)) {
|
|
auto [ss, size] = make_sockaddr_storage(
|
|
state->proxy_destination_patch.first,
|
|
state->proxy_destination_patch.second);
|
|
state->proxy_server->listen(pc->addr, pc->port, pc->version, &ss);
|
|
} else if (is_v4(pc->version)) {
|
|
auto [ss, size] = make_sockaddr_storage(
|
|
state->proxy_destination_bb.first,
|
|
state->proxy_destination_bb.second);
|
|
state->proxy_server->listen(pc->addr, pc->port, pc->version, &ss);
|
|
} else {
|
|
state->proxy_server->listen(pc->addr, pc->port, pc->version);
|
|
}
|
|
}
|
|
|
|
} else if (pc->behavior == ServerBehavior::PATCH_SERVER_PC) {
|
|
if (!state->pc_patch_server.get()) {
|
|
config_log.info("Starting PC_V2 patch server");
|
|
state->pc_patch_server = make_shared<PatchServer>(state->generate_patch_server_config(false));
|
|
}
|
|
string spec = string_printf("TU-%hu-%s-patch2", pc->port, pc->name.c_str());
|
|
state->pc_patch_server->listen(spec, pc->addr, pc->port, Version::PC_PATCH);
|
|
|
|
} else if (pc->behavior == ServerBehavior::PATCH_SERVER_BB) {
|
|
if (!state->bb_patch_server.get()) {
|
|
config_log.info("Starting BB_V4 patch server");
|
|
state->bb_patch_server = make_shared<PatchServer>(state->generate_patch_server_config(true));
|
|
}
|
|
string spec = string_printf("TU-%hu-%s-patch4", pc->port, pc->name.c_str());
|
|
state->bb_patch_server->listen(spec, pc->addr, pc->port, Version::BB_PATCH);
|
|
|
|
} else {
|
|
if (!state->game_server.get()) {
|
|
config_log.info("Starting game server");
|
|
state->game_server = make_shared<Server>(base, state);
|
|
}
|
|
string spec = string_printf("TG-%hu-%s-%s-%s", pc->port, name_for_enum(pc->version), pc->name.c_str(), name_for_enum(pc->behavior));
|
|
state->game_server->listen(spec, pc->addr, pc->port, pc->version, pc->behavior);
|
|
}
|
|
}
|
|
|
|
if (!state->ip_stack_addresses.empty() || !state->ppp_stack_addresses.empty()) {
|
|
config_log.info("Starting IP/PPP stack simulator");
|
|
ip_stack_simulator = make_shared<IPStackSimulator>(base, state);
|
|
for (const auto& it : state->ip_stack_addresses) {
|
|
auto netloc = parse_netloc(it);
|
|
string spec = (netloc.second == 0) ? ("T-IPS-" + netloc.first) : string_printf("T-IPS-%hu", netloc.second);
|
|
ip_stack_simulator->listen(spec, netloc.first, netloc.second, IPStackSimulator::Protocol::ETHERNET_TAPSERVER);
|
|
}
|
|
for (const auto& it : state->ppp_stack_addresses) {
|
|
auto netloc = parse_netloc(it);
|
|
string spec = (netloc.second == 0) ? ("T-PPPST-" + netloc.first) : string_printf("T-PPPST-%hu", netloc.second);
|
|
ip_stack_simulator->listen(spec, netloc.first, netloc.second, IPStackSimulator::Protocol::HDLC_TAPSERVER);
|
|
}
|
|
for (const auto& it : state->ppp_raw_addresses) {
|
|
auto netloc = parse_netloc(it);
|
|
string spec = (netloc.second == 0) ? ("T-PPPSR-" + netloc.first) : string_printf("T-PPPSR-%hu", netloc.second);
|
|
ip_stack_simulator->listen(spec, netloc.first, netloc.second, IPStackSimulator::Protocol::HDLC_RAW);
|
|
}
|
|
}
|
|
|
|
if (!state->http_addresses.empty() || !state->http_addresses.empty()) {
|
|
config_log.info("Starting HTTP server");
|
|
http_server = make_shared<HTTPServer>(state);
|
|
for (const auto& it : state->http_addresses) {
|
|
auto netloc = parse_netloc(it);
|
|
http_server->listen(netloc.first, netloc.second);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!state->username.empty()) {
|
|
config_log.info("Switching to user %s", state->username.c_str());
|
|
drop_privileges(state->username);
|
|
}
|
|
|
|
bool should_run_shell;
|
|
if (state->run_shell_behavior == ServerState::RunShellBehavior::DEFAULT) {
|
|
should_run_shell = isatty(fileno(stdin));
|
|
} else if (state->run_shell_behavior == ServerState::RunShellBehavior::ALWAYS) {
|
|
should_run_shell = true;
|
|
} else {
|
|
should_run_shell = false;
|
|
}
|
|
if (should_run_shell) {
|
|
should_run_shell = !replay_session.get();
|
|
}
|
|
|
|
config_log.info("Ready");
|
|
if (should_run_shell) {
|
|
shell = make_shared<ServerShell>(state);
|
|
}
|
|
|
|
event_base_dispatch(base.get());
|
|
if (replay_session) {
|
|
// If in a replay session, run the event loop for a bit longer to make
|
|
// sure the server doesn't send anything unexpected after the end of
|
|
// the session.
|
|
auto tv = usecs_to_timeval(500000);
|
|
event_base_loopexit(base.get(), &tv);
|
|
event_base_dispatch(base.get());
|
|
}
|
|
|
|
config_log.info("Normal shutdown");
|
|
if (state->pc_patch_server) {
|
|
state->pc_patch_server->schedule_stop();
|
|
}
|
|
if (state->bb_patch_server) {
|
|
state->bb_patch_server->schedule_stop();
|
|
}
|
|
if (http_server) {
|
|
http_server->schedule_stop();
|
|
}
|
|
if (state->pc_patch_server) {
|
|
config_log.info("Waiting for PC_V2 patch server to stop");
|
|
state->pc_patch_server->wait_for_stop();
|
|
}
|
|
if (state->bb_patch_server) {
|
|
config_log.info("Waiting for BB_V4 patch server to stop");
|
|
state->bb_patch_server->wait_for_stop();
|
|
}
|
|
if (http_server) {
|
|
config_log.info("Waiting for HTTP server to stop");
|
|
http_server->wait_for_stop();
|
|
}
|
|
state->proxy_server.reset(); // Break reference cycle
|
|
});
|
|
|
|
void print_version_info() {
|
|
string build_date = format_time(BUILD_TIMESTAMP);
|
|
fprintf(stderr, "newserv-%s built %s UTC\n", GIT_REVISION_HASH, build_date.c_str());
|
|
}
|
|
|
|
void print_usage() {
|
|
print_version_info();
|
|
fputs("\n\
|
|
Usage:\n\
|
|
newserv [ACTION] [OPTIONS...]\n\
|
|
\n\
|
|
If ACTION is not specified, newserv runs in server mode. PSO clients can\n\
|
|
connect normally, join lobbies, play games, and use the proxy server. See\n\
|
|
README.md and system/config.json for more information.\n\
|
|
\n\
|
|
When ACTION is given, newserv will do things other than running the server.\n\
|
|
\n\
|
|
Some actions accept input and/or output filenames; see the descriptions below\n\
|
|
for details. If INPUT-FILENAME is missing or is '-', newserv reads from stdin.\n\
|
|
If OUTPUT-FILENAME is missing and the input is not from stdin, newserv writes\n\
|
|
the output to INPUT-FILENAME.dec or a similarly-named file; if OUTPUT-FILENAME\n\
|
|
is '-', newserv writes the output to stdout. If stdout is a terminal and the\n\
|
|
output is not text or JSON, the data written to stdout is formatted in a\n\
|
|
hex/ASCII view; in any other case, the raw output is written to stdout, which\n\
|
|
(for most actions) may include arbitrary binary data.\n\
|
|
\n\
|
|
The actions are:\n",
|
|
stderr);
|
|
for (const auto& a : action_order) {
|
|
if (a->help_text) {
|
|
fputs(a->help_text, stderr);
|
|
}
|
|
}
|
|
fputs("\n\
|
|
Most options that take data as input also accept the following option:\n\
|
|
--parse-data\n\
|
|
For modes that take input (from a file or from stdin), parse the input as\n\
|
|
a hex string before encrypting/decoding/etc.\n\
|
|
\n\
|
|
Many versions also accept or require a version option. The version options are:\n\
|
|
--pc-patch: PC patch server\n\
|
|
--bb-patch: BB patch server\n\
|
|
--dc-nte: DC Network Trial Edition\n\
|
|
--dc-proto or --dc-11-2000: DC 11/2000 prototype\n\
|
|
--dc-v1: DC v1\n\
|
|
--dc-v2 or --dc: DC v2\n\
|
|
--pc-nte: PC Network Trial Edition\n\
|
|
--pc: PC v2\n\
|
|
--gc-nte: GC Episodes 1&2 Trial Edition\n\
|
|
--gc: GC Episodes 1&2\n\
|
|
--xb: Xbox Episodes 1&2\n\
|
|
--ep3-nte: GC Episode 3 Trial Edition\n\
|
|
--ep3: GC Episode 3\n\
|
|
--bb: Blue Burst\n\
|
|
\n",
|
|
stderr);
|
|
}
|
|
|
|
int main(int argc, char** argv) {
|
|
Arguments args(&argv[1], argc - 1);
|
|
if (args.get<bool>("help")) {
|
|
print_usage();
|
|
return 0;
|
|
}
|
|
|
|
string action_name = args.get<string>(0, false);
|
|
const Action* a;
|
|
try {
|
|
a = all_actions.at(action_name);
|
|
} catch (const out_of_range&) {
|
|
log_error("Unknown or invalid action; try --help");
|
|
return 1;
|
|
}
|
|
a->run(args);
|
|
return 0;
|
|
}
|