4288 lines
189 KiB
C++
4288 lines
189 KiB
C++
#include <phosg/Platform.hh>
|
|
|
|
#include <signal.h>
|
|
#include <string.h>
|
|
#ifndef PHOSG_WINDOWS
|
|
#include <pwd.h>
|
|
#endif
|
|
|
|
#include <asio.hpp>
|
|
#include <filesystem>
|
|
#include <mutex>
|
|
#include <phosg/Arguments.hh>
|
|
#include <phosg/Filesystem.hh>
|
|
#include <phosg/JSON.hh>
|
|
#include <phosg/Math.hh>
|
|
#include <phosg/Network.hh>
|
|
#include <phosg/Platform.hh>
|
|
#include <phosg/Strings.hh>
|
|
#include <phosg/Tools.hh>
|
|
#include <set>
|
|
#include <thread>
|
|
#include <unordered_map>
|
|
|
|
#include "AddressTranslator.hh"
|
|
#include "BMLArchive.hh"
|
|
#include "CommonFileFormats.hh"
|
|
#include "Compression.hh"
|
|
#include "DCSerialNumbers.hh"
|
|
#include "DNSServer.hh"
|
|
#include "DOLFileIndex.hh"
|
|
#include "DownloadSession.hh"
|
|
#include "GSLArchive.hh"
|
|
#include "GameServer.hh"
|
|
#include "HTTPServer.hh"
|
|
#include "IPStackSimulator.hh"
|
|
#include "ImageEncoder.hh"
|
|
#include "Loggers.hh"
|
|
#include "NetworkAddresses.hh"
|
|
#include "PPKArchive.hh"
|
|
#include "PSOGCObjectGraph.hh"
|
|
#include "PSOProtocol.hh"
|
|
#include "PatchDownloadSession.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 "SignalWatcher.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();
|
|
|
|
std::string get_config_filename(phosg::Arguments& args) {
|
|
string config_filename = args.get<string>("config");
|
|
return config_filename.empty() ? "system/config.json" : config_filename;
|
|
}
|
|
|
|
template <typename T>
|
|
vector<T> parse_int_vector(const phosg::JSON& o) {
|
|
vector<T> ret;
|
|
for (const auto& x : o.as_list()) {
|
|
ret.emplace_back(x->as_int());
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
#ifndef PHOSG_WINDOWS
|
|
void drop_privileges(const string& username) {
|
|
if ((getuid() != 0) || (getgid() != 0)) {
|
|
throw runtime_error(std::format(
|
|
"newserv was not started as root; can\'t switch to user {}",
|
|
username));
|
|
}
|
|
|
|
struct passwd* pw = getpwnam(username.c_str());
|
|
if (!pw) {
|
|
string error = phosg::string_for_error(errno);
|
|
throw runtime_error(std::format("user {} not found ({})",
|
|
username, error));
|
|
}
|
|
|
|
if (setgid(pw->pw_gid) != 0) {
|
|
string error = phosg::string_for_error(errno);
|
|
throw runtime_error(std::format("can\'t switch to group {} ({})",
|
|
pw->pw_gid, error));
|
|
}
|
|
if (setuid(pw->pw_uid) != 0) {
|
|
string error = phosg::string_for_error(errno);
|
|
throw runtime_error(std::format("can\'t switch to user {} ({})",
|
|
pw->pw_uid, error));
|
|
}
|
|
config_log.info_f("Switched to user {} ({}:{})", username, pw->pw_uid, pw->pw_gid);
|
|
}
|
|
#endif
|
|
|
|
Version get_cli_version(phosg::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_11_2000;
|
|
} 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") || args.get<bool>("pc-v2")) {
|
|
return Version::PC_V2;
|
|
} else if (args.get<bool>("gc-nte")) {
|
|
return Version::GC_NTE;
|
|
} else if (args.get<bool>("gc") || args.get<bool>("gc-v3")) {
|
|
return Version::GC_V3;
|
|
} else if (args.get<bool>("xb") || args.get<bool>("xb-v3")) {
|
|
return Version::XB_V3;
|
|
} else if (args.get<bool>("ep3-nte") || args.get<bool>("gc-ep3-nte")) {
|
|
return Version::GC_EP3_NTE;
|
|
} else if (args.get<bool>("ep3") || args.get<bool>("gc-ep3")) {
|
|
return Version::GC_EP3;
|
|
} else if (args.get<bool>("bb") || args.get<bool>("bb-v4")) {
|
|
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(phosg::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(phosg::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(phosg::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(phosg::Arguments& args) {
|
|
const string& input_filename = args.get<string>(1, false);
|
|
|
|
string data;
|
|
if (!input_filename.empty() && (input_filename != "-")) {
|
|
data = phosg::load_file(input_filename);
|
|
} else {
|
|
data = phosg::read_all(stdin);
|
|
}
|
|
if (args.get<bool>("parse-data")) {
|
|
data = phosg::parse_data_string(data, nullptr, phosg::ParseDataFlags::ALLOW_FILES);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
bool is_text_extension(const char* extension) {
|
|
return (!strcmp(extension, "txt") || !strcmp(extension, "json") || !strcmp(extension, "reg"));
|
|
}
|
|
|
|
void write_output_data(phosg::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
|
|
phosg::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;
|
|
phosg::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
|
|
phosg::print_data(stdout, data, size);
|
|
fflush(stdout);
|
|
|
|
} else {
|
|
// If stdout is not a terminal, write the data as-is
|
|
phosg::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(phosg::Arguments& args)> run;
|
|
|
|
Action(
|
|
const char* name,
|
|
const char* help_text,
|
|
function<void(phosg::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(std::format("multiple actions with the same name: {}", this->name));
|
|
}
|
|
action_order.emplace_back(this);
|
|
}
|
|
};
|
|
|
|
Action a_help(
|
|
"help", "\
|
|
help\n\
|
|
You\'re reading it now.\n",
|
|
+[](phosg::Arguments&) -> void {
|
|
print_usage();
|
|
});
|
|
|
|
Action a_version(
|
|
"version", "\
|
|
version\n\
|
|
Show newserv\'s revision and build date.\n",
|
|
+[](phosg::Arguments&) -> void {
|
|
print_version_info();
|
|
});
|
|
|
|
static void a_compress_decompress_fn(phosg::Arguments& args) {
|
|
const auto& action = args.get<string>(0);
|
|
bool is_prs = action.ends_with("-prs");
|
|
bool is_bc0 = action.ends_with("-bc0");
|
|
bool is_pr2 = action.ends_with("-pr2");
|
|
bool is_prc = action.ends_with("-prc");
|
|
bool is_decompress = action.starts_with("decompress-");
|
|
bool is_big_endian = args.get<bool>("big-endian");
|
|
bool is_optimal = args.get<bool>("optimal");
|
|
bool is_pessimal = args.get<bool>("pessimal");
|
|
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;
|
|
phosg::fwrite_fmt(stderr, "... {}/{} ({:g}%) => {} ({: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 = phosg::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;
|
|
phosg::fwrite_fmt(stderr, "... [{}] {}/{} ({:g}%) => {} ({:g}%) \r",
|
|
phase_name, input_progress, input_bytes, progress, output_progress, size_ratio);
|
|
};
|
|
|
|
uint64_t start = phosg::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 if (is_pessimal) {
|
|
data = prs_compress_pessimal(data.data(), data.size());
|
|
} 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 = phosg::now();
|
|
string time_str = phosg::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 = phosg::format_size(bytes_per_sec);
|
|
phosg::log_info_f("{} (0x{:X}) bytes input => {} (0x{:X}) bytes output ({:g}%) in {} ({} / sec)",
|
|
input_bytes, input_bytes, data.size(), data.size(), size_ratio, time_str, bytes_per_sec_str);
|
|
|
|
if (is_pr2 || is_prc) {
|
|
if (is_decompress && (data.size() != pr2_expected_size)) {
|
|
phosg::log_warning_f("Result data size ({} bytes) does not match expected size from PR2 header ({} bytes)", data.size(), pr2_expected_size);
|
|
} else if (!is_decompress) {
|
|
uint32_t pr2_seed = seed.empty() ? phosg::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",
|
|
+[](phosg::Arguments& args) {
|
|
string data = read_input_data(args);
|
|
size_t input_bytes = data.size();
|
|
size_t output_bytes = prs_decompress_size(data);
|
|
phosg::log_info_f("{} (0x{:X}) bytes input => {} (0x{:X}) bytes output",
|
|
input_bytes, input_bytes, output_bytes, output_bytes);
|
|
});
|
|
|
|
Action a_disassemble_prs(
|
|
"disassemble-prs", nullptr, +[](phosg::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",
|
|
+[](phosg::Arguments& args) {
|
|
bc0_disassemble(stdout, read_input_data(args));
|
|
});
|
|
|
|
Action a_psov2_encrypt_single_test(
|
|
"psov2-encrypt-single-test", nullptr,
|
|
[](phosg::Arguments& args) {
|
|
size_t num_threads = args.get<size_t>("threads", std::thread::hardware_concurrency());
|
|
|
|
vector<uint64_t> crypt_times(num_threads, 0);
|
|
vector<uint64_t> single_times(num_threads, 0);
|
|
uint64_t num_mismatches = 0;
|
|
mutex output_lock;
|
|
auto thread_fn = [&](uint64_t seed, size_t thread_index) -> bool {
|
|
uint64_t start_t = phosg::now();
|
|
uint32_t crypt_v = PSOV2Encryption(seed).next();
|
|
uint64_t mid_t = phosg::now();
|
|
uint32_t single_v = PSOV2Encryption::single(seed);
|
|
uint64_t end_t = phosg::now();
|
|
|
|
crypt_times[thread_index] += (mid_t - start_t);
|
|
single_times[thread_index] += (end_t - mid_t);
|
|
|
|
if (crypt_v != single_v) {
|
|
lock_guard g(output_lock);
|
|
phosg::fwrite_fmt(stderr, "Mismatched result on seed {:08X}: crypt={:08X}, single={:08X}\n",
|
|
seed, crypt_v, single_v);
|
|
num_mismatches++;
|
|
}
|
|
return false;
|
|
};
|
|
auto progress_fn = [&](uint64_t, uint64_t, uint64_t current_value, uint64_t) -> void {
|
|
uint64_t crypt_time = 0, single_time = 0;
|
|
for (uint64_t t : crypt_times) {
|
|
crypt_time += t;
|
|
}
|
|
for (uint64_t t : single_times) {
|
|
single_time += t;
|
|
}
|
|
lock_guard g(output_lock);
|
|
phosg::log_info_f("... {:08X} => {} mismatches, {} crypt, {} single ({:g}x)",
|
|
current_value, num_mismatches, phosg::format_duration(crypt_time), phosg::format_duration(single_time),
|
|
static_cast<float>(crypt_time) / single_time);
|
|
};
|
|
phosg::parallel_blocks<uint64_t>(thread_fn, 0, 0x100000000, 0x1000, num_threads, progress_fn);
|
|
|
|
progress_fn(0, 0, 0xFFFFFFFF, 0);
|
|
});
|
|
|
|
static void a_encrypt_decrypt_fn(phosg::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_11_2000:
|
|
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 = phosg::parse_data_string(seed, nullptr, phosg::ParseDataFlags::ALLOW_FILES);
|
|
auto key = phosg::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] = phosg::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] = phosg::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(phosg::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;
|
|
}
|
|
}
|
|
phosg::fwrite_fmt(stderr, "Basis appears to be {:02X} ({} 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, +[](phosg::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_parse_pc_v2_registry(
|
|
"parse-pc-v2-registry", "\
|
|
parse-pc-v2-registry [INPUT-FILENAME]\n\
|
|
Decrypt and show the encrypted serial number, access key, and email fields\n\
|
|
from the given registry export. The input file should be a .reg file\n\
|
|
exported from the HKEY_CURRENT_USER\\Software\\SonicTeam\\PSOV2 key.\n",
|
|
+[](phosg::Arguments& args) {
|
|
string data = read_input_data(args);
|
|
if (data.starts_with("\xFF\xFE")) {
|
|
data = tt_utf16_to_utf8(data.substr(2));
|
|
}
|
|
data = phosg::str_replace_all(data, "\r", "");
|
|
data = phosg::str_replace_all(data, "\\\n", "");
|
|
|
|
bool in_psov2_section = false;
|
|
string serial_data, access_data, email_data;
|
|
for (string line : phosg::split(data, '\n')) {
|
|
if (line.starts_with("[")) {
|
|
in_psov2_section = (line == "[HKEY_CURRENT_USER\\Software\\SonicTeam\\PSOV2]");
|
|
} else if (!in_psov2_section) {
|
|
// Wrong section; skip the line
|
|
} else if (line.starts_with("\"SERIAL\"=hex:")) {
|
|
serial_data = phosg::parse_data_string(line.substr(13));
|
|
} else if (line.starts_with("\"ACCESS\"=hex:")) {
|
|
access_data = phosg::parse_data_string(line.substr(13));
|
|
} else if (line.starts_with("\"E-MAIL\"=hex:")) {
|
|
email_data = phosg::parse_data_string(line.substr(13));
|
|
}
|
|
}
|
|
|
|
if (serial_data.size() != 8) {
|
|
throw std::runtime_error("serial number data is missing or incorrect size");
|
|
}
|
|
if (access_data.size() != 8) {
|
|
throw std::runtime_error("access key data is missing or incorrect size");
|
|
}
|
|
if (email_data.size() != 0x40) {
|
|
throw std::runtime_error("email data is missing or incorrect size");
|
|
}
|
|
|
|
serial_data = decrypt_v2_registry_value(serial_data);
|
|
access_data = decrypt_v2_registry_value(access_data);
|
|
email_data = decrypt_v2_registry_value(email_data);
|
|
uint32_t serial_number = stoul(serial_data, nullptr, 16);
|
|
phosg::strip_trailing_zeroes(access_data);
|
|
phosg::strip_trailing_zeroes(email_data);
|
|
phosg::fwrite_fmt(stderr, "Serial number (decimal): {}\nSerial number (hex): {:08X}\nAccess key: {}\nEmail address: {}\n",
|
|
serial_number, serial_number, access_data, email_data);
|
|
});
|
|
|
|
Action a_generate_pc_v2_registry(
|
|
"generate-pc-v2-registry", "\
|
|
generate-pc-v2-registry <OPTIONS> [OUTPUT-FILENAME]\n\
|
|
Generate a .reg file containing PSO PC v2 credentials suitable for\n\
|
|
importing into the Windows registry. The following options are required:\n\
|
|
--serial-number=SERIAL-NUMBER (decimal serial number)\n\
|
|
--access-key=ACCESS-KEY (access key, 8 characters)\n\
|
|
--email=EMAIL (email address)\n",
|
|
+[](phosg::Arguments& args) {
|
|
auto hex_str_for_data = +[](const string& data) -> string {
|
|
if (data.size() == 0) {
|
|
return string();
|
|
}
|
|
string ret = std::format("{:02x}", data[0]);
|
|
for (size_t z = 1; z < data.size(); z++) {
|
|
ret += std::format(",{:02x}", data[z]);
|
|
}
|
|
return ret;
|
|
};
|
|
|
|
uint32_t serial_number = args.get<uint32_t>("serial-number", 0);
|
|
string access_key = args.get<string>("access-key", true);
|
|
string email = args.get<string>("email", true);
|
|
if (access_key.size() != 8) {
|
|
throw std::runtime_error("access key is not exactly 8 characters");
|
|
}
|
|
if (email.size() > 0x40) {
|
|
throw std::runtime_error("email address is too long");
|
|
}
|
|
email.resize(0x40, '\0');
|
|
|
|
string serial_data = decrypt_v2_registry_value(std::format("{:08X}", serial_number));
|
|
string access_data = decrypt_v2_registry_value(access_key);
|
|
string email_data = decrypt_v2_registry_value(email);
|
|
|
|
string serial_hex = hex_str_for_data(serial_data);
|
|
string access_hex = hex_str_for_data(access_data);
|
|
string email_hex = hex_str_for_data(email_data);
|
|
|
|
string output_data = std::format("Windows Registry Editor Version 5.00\r\n\r\n[HKEY_CURRENT_USER\\Software\\SonicTeam\\PSOV2]\r\n\r\n\"SERIAL\"=hex:{}\r\n\"ACCESS\"=hex:{}\r\n\"E-MAIL\"=hex:{}\r\n",
|
|
serial_hex, access_hex, email_hex);
|
|
|
|
write_output_data(args, output_data.data(), output_data.size(), "reg");
|
|
});
|
|
|
|
Action a_encrypt_challenge_time(
|
|
"encrypt-challenge-time", nullptr, +[](phosg::Arguments& args) {
|
|
uint16_t time = args.get<uint16_t>(1);
|
|
uint32_t ret = encrypt_challenge_time(time);
|
|
phosg::fwrite_fmt(stderr, "{} => {:08X}\n", phosg::format_duration(time * 1000000), ret);
|
|
});
|
|
Action a_decrypt_challenge_time(
|
|
"decrypt-challenge-time", nullptr, +[](phosg::Arguments& args) {
|
|
uint32_t time = args.get<uint32_t>(1, phosg::Arguments::IntFormat::HEX);
|
|
uint16_t ret = decrypt_challenge_time(time);
|
|
phosg::fwrite_fmt(stderr, "{:08X} => {}\n", time, phosg::format_duration(ret * 1000000));
|
|
});
|
|
|
|
Action a_encrypt_challenge_data(
|
|
"encrypt-challenge-data", nullptr, +[](phosg::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",
|
|
+[](phosg::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_vms_save_fn(phosg::Arguments& args) {
|
|
bool is_decrypt = (args.get<string>(0) == "decrypt-vms-save");
|
|
bool skip_checksum = args.get<bool>("skip-checksum");
|
|
string serial_number_str = args.get<string>("serial-number");
|
|
int64_t override_round2_seed = args.get<int64_t>("round2-seed", -1, phosg::Arguments::IntFormat::HEX);
|
|
|
|
int64_t round1_seed = serial_number_str.empty() ? -1 : stoul(serial_number_str, nullptr, 16);
|
|
|
|
auto data = read_input_data(args);
|
|
phosg::StringReader r(data);
|
|
const auto& header = r.get<PSOVMSFileHeader>();
|
|
header.check();
|
|
r.skip(header.icon_data_size());
|
|
|
|
size_t data_start_offset = r.where();
|
|
|
|
auto process_file = [&]<typename StructT, bool UseIterator, size_t ChecksumLength = sizeof(StructT)>() {
|
|
if (is_decrypt) {
|
|
const void* data_section = r.getv(header.data_size);
|
|
if (round1_seed < 0) {
|
|
size_t num_threads = args.get<size_t>("threads", 0);
|
|
if (num_threads == 0) {
|
|
num_threads = thread::hardware_concurrency();
|
|
}
|
|
|
|
mutex output_lock;
|
|
if (UseIterator) {
|
|
DCSerialNumberIterator iter;
|
|
mutex iter_lock;
|
|
atomic<bool> seed_found = false;
|
|
auto thread_fn = [&]() -> void {
|
|
for (;;) {
|
|
uint32_t serial_number;
|
|
{
|
|
lock_guard g(iter_lock);
|
|
serial_number = iter.next();
|
|
}
|
|
if (serial_number == 0) {
|
|
return;
|
|
}
|
|
try {
|
|
auto decrypted = decrypt_fixed_size_data_section_t<StructT, false, ChecksumLength>(
|
|
data_section, sizeof(StructT), serial_number, skip_checksum, override_round2_seed);
|
|
|
|
seed_found = true;
|
|
{
|
|
lock_guard g(iter_lock);
|
|
iter.complete = true;
|
|
}
|
|
lock_guard g(output_lock);
|
|
phosg::fwrite_fmt(stderr, "\nFound serial number: {:08X}\n", serial_number);
|
|
*reinterpret_cast<StructT*>(data.data() + data_start_offset) = decrypted;
|
|
|
|
} catch (const runtime_error&) {
|
|
}
|
|
}
|
|
};
|
|
|
|
vector<thread> threads;
|
|
while (threads.size() < num_threads) {
|
|
threads.emplace_back(thread_fn);
|
|
}
|
|
for (;;) {
|
|
usleep(1000000);
|
|
lock_guard g(iter_lock);
|
|
size_t progress = iter.progress();
|
|
size_t total_count = iter.total_count();
|
|
float progress_percent = static_cast<float>(progress * 100) / total_count;
|
|
phosg::fwrite_fmt(stderr, "... {}/{} ({:g}%, domain {:02X}, subdomain {:02X}, index2 {:04X}, index3 {:04X})\r",
|
|
progress, total_count, progress_percent, iter.domain, iter.subdomain, iter.index2, iter.index3);
|
|
if (iter.complete) {
|
|
break;
|
|
}
|
|
}
|
|
for (auto& th : threads) {
|
|
th.join();
|
|
}
|
|
if (!seed_found) {
|
|
throw runtime_error("no seed found");
|
|
}
|
|
|
|
} else {
|
|
uint64_t seed = phosg::parallel_blocks<uint64_t>([&](uint64_t serial_number, size_t) -> bool {
|
|
try {
|
|
auto decrypted = decrypt_fixed_size_data_section_t<StructT, false, ChecksumLength>(
|
|
data_section, sizeof(StructT), serial_number, skip_checksum, override_round2_seed);
|
|
|
|
lock_guard g(output_lock);
|
|
phosg::fwrite_fmt(stderr, "\nFound serial number: {:08X}\n", serial_number);
|
|
*reinterpret_cast<StructT*>(data.data() + data_start_offset) = decrypted;
|
|
return true;
|
|
|
|
} catch (const runtime_error&) {
|
|
return false;
|
|
}
|
|
},
|
|
0, 0x100000000, 0x1000, num_threads);
|
|
if (seed >= 0x100000000) {
|
|
throw runtime_error("no seed found");
|
|
}
|
|
}
|
|
|
|
} else {
|
|
auto decrypted = decrypt_fixed_size_data_section_t<StructT, false, ChecksumLength>(
|
|
data_section, sizeof(StructT), 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, false, ChecksumLength>(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());
|
|
}
|
|
};
|
|
|
|
bool is_v2 = header.is_v2();
|
|
if (!is_v2 && (header.data_size == sizeof(PSODCNTECharacterFile))) {
|
|
phosg::fwrite_fmt(stderr, "File type: DC NTE character\n");
|
|
process_file.template operator()<PSODCNTECharacterFile, false>();
|
|
} else if (!is_v2 && (header.data_size == sizeof(PSODCNTEGuildCardFile))) {
|
|
phosg::fwrite_fmt(stderr, "File type: DC NTE Guild Card list\n");
|
|
throw runtime_error("DC NTE Guild Card files are not encrypted");
|
|
} else if (!is_v2 && (header.data_size == sizeof(PSODC112000CharacterFile))) {
|
|
phosg::fwrite_fmt(stderr, "File type: DC 11/2000 character\n");
|
|
process_file.template operator()<PSODC112000CharacterFile, false>();
|
|
} else if (!is_v2 && (header.data_size == sizeof(PSODC112000GuildCardFile))) {
|
|
phosg::fwrite_fmt(stderr, "File type: DC 11/2000 Guild Card list\n");
|
|
throw runtime_error("DC 11/2000 Guild Card files are not encrypted");
|
|
} else if (!is_v2 && (header.data_size == sizeof(PSODCV1CharacterFile))) {
|
|
phosg::fwrite_fmt(stderr, "File type: DC v1 character\n");
|
|
process_file.template operator()<PSODCV1CharacterFile, true>();
|
|
} else if (is_v2 && (header.data_size == sizeof(PSODCV2CharacterFile))) {
|
|
phosg::fwrite_fmt(stderr, "File type: DC v2 character\n");
|
|
process_file.template operator()<PSODCV2CharacterFile, true>();
|
|
} else if (header.data_size == sizeof(PSODCV1V2GuildCardFile)) {
|
|
// There appears to be a copy/paste error here: the game uses the character file size when checksumming the Guild
|
|
// Card file, so we must do the same
|
|
if (!is_v2) {
|
|
phosg::fwrite_fmt(stderr, "File type: DC v1 Guild Card list\n");
|
|
static_assert(sizeof(PSODCV1CharacterFile) <= sizeof(PSODCV1V2GuildCardFile::EncryptedSection));
|
|
process_file.template operator()<PSODCV1V2GuildCardFile::EncryptedSection, true, sizeof(PSODCV1CharacterFile)>();
|
|
} else {
|
|
phosg::fwrite_fmt(stderr, "File type: DC v2 Guild Card list\n");
|
|
static_assert(sizeof(PSODCV2CharacterFile) <= sizeof(PSODCV1V2GuildCardFile::EncryptedSection));
|
|
process_file.template operator()<PSODCV1V2GuildCardFile::EncryptedSection, true, sizeof(PSODCV2CharacterFile)>();
|
|
}
|
|
} else {
|
|
throw runtime_error("unrecognized save type");
|
|
}
|
|
|
|
write_output_data(args, data.data(), data.size(), is_decrypt ? "vmsd" : "vms");
|
|
}
|
|
|
|
Action a_decrypt_vms_save("decrypt-vms-save", nullptr, a_encrypt_decrypt_vms_save_fn);
|
|
Action a_encrypt_vms_save("encrypt-vms-save", "\
|
|
encrypt-vms-save --seed=SEED INPUT-FILENAME [OUTPUT-FILENAME]\n\
|
|
decrypt-vms-save [--seed=SEED] INPUT-FILENAME [OUTPUT-FILENAME]\n\
|
|
Encrypt or decrypt a character or Guild Card file in VMS format. If\n\
|
|
encrypting, the checksum is also recomputed and stored in the encrypted\n\
|
|
file. --seed is the encryption seed (serial number) specified as a 32-bit\n\
|
|
hexadecimal value.\n",
|
|
a_encrypt_decrypt_vms_save_fn);
|
|
|
|
static void a_encrypt_decrypt_pc_save_fn(phosg::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, phosg::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].encrypted = decrypt_fixed_size_data_section_t<PSOPCCharacterFile::CharacterEntry::EncryptedSection, false>(
|
|
&charfile->entries[z].encrypted, sizeof(charfile->entries[z].encrypted), round1_seed, skip_checksum, override_round2_seed);
|
|
} catch (const exception& e) {
|
|
phosg::fwrite_fmt(stderr, "warning: cannot decrypt character {}: {}\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::EncryptedSection, false>(
|
|
charfile->entries[z].encrypted, round1_seed);
|
|
if (encrypted.size() != sizeof(PSOPCCharacterFile::CharacterEntry::EncryptedSection)) {
|
|
throw logic_error("incorrect encrypted result size");
|
|
}
|
|
charfile->entries[z].encrypted = *reinterpret_cast<const PSOPCCharacterFile::CharacterEntry::EncryptedSection*>(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(phosg::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, phosg::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);
|
|
phosg::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");
|
|
}
|
|
|
|
static void a_encrypt_decrypt_gci_save_fn(phosg::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, phosg::Arguments::IntFormat::HEX);
|
|
|
|
uint32_t round1_seed;
|
|
if (!system_filename.empty()) {
|
|
string system_data = phosg::load_file(system_filename);
|
|
phosg::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);
|
|
phosg::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(phosg::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);
|
|
|
|
Action a_decrypt_xbox_save(
|
|
"decrypt-xbox-save", "\
|
|
decrypt-xbox-save CRYPT-OPTION INPUT-FILENAME [OUTPUT-FILENAME]\n\
|
|
Decrypt a character or Guild Card file in Xbox format. CRYPT-OPTION is\n\
|
|
required; it can be either --sys=SYSTEM-FILENAME (specifying the name of\n\
|
|
the corresponding PSO_SYSTEM file) or --seed=ROUND1-SEED (specified as a\n\
|
|
32-bit hexadecimal number).\n",
|
|
+[](phosg::Arguments& args) {
|
|
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, phosg::Arguments::IntFormat::HEX);
|
|
|
|
uint32_t round1_seed;
|
|
if (!system_filename.empty()) {
|
|
string system_data = phosg::load_file(system_filename);
|
|
phosg::StringReader r(system_data);
|
|
const auto& header = r.get<PSOXBFileHeader>();
|
|
header.check();
|
|
const auto& system = r.get<PSOXBSystemFile>();
|
|
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);
|
|
phosg::StringReader r(data);
|
|
const auto& header = r.get<PSOXBFileHeader>();
|
|
header.check();
|
|
|
|
size_t data_start_offset = r.where();
|
|
|
|
auto process_file = [&]<typename StructT>() {
|
|
const void* data_section = r.getv(header.data_size);
|
|
auto decrypted = decrypt_fixed_size_data_section_t<StructT, false>(
|
|
data_section, header.data_size, round1_seed, skip_checksum, override_round2_seed);
|
|
*reinterpret_cast<StructT*>(data.data() + data_start_offset) = decrypted;
|
|
};
|
|
|
|
if (header.data_size == sizeof(PSOXBGuildCardFile)) {
|
|
process_file.template operator()<PSOXBGuildCardFile>();
|
|
} else if (header.data_size == sizeof(PSOXBCharacterFile)) {
|
|
process_file.template operator()<PSOXBCharacterFile>();
|
|
} else {
|
|
throw runtime_error("unrecognized save type");
|
|
}
|
|
|
|
write_output_data(args, data.data(), 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_decrypt_dcv2_executable(
|
|
"decrypt-dcv2-executable", "\
|
|
decrypt-dcv2-executable --executable=EXEC --indexes=INDEXES --values=VALUES\n\
|
|
decrypt-dcv2-executable --executable=EXEC --simple [--seed=SEED]\n\
|
|
Decrypt a PSO DC v2 executable file. EXEC should be the path to the\n\
|
|
executable (DP_ADDRESS.JPN), INDEXES should be the path to the index fixup\n\
|
|
table (KATSUO.SEA), and VALUES should be the path to the value fixup table\n\
|
|
(IWASHI.SEA). The output is written to EXEC.dec.\n\
|
|
If --simple is given, uses the encryption method used in Ives\' Enhancement\n\
|
|
Pack. In this case, --seed is not required; if not given, finds the seed\n\
|
|
automatically, and prints it to stderr so you will be able to use it when\n\
|
|
re-encrypting.\n",
|
|
+[](phosg::Arguments& args) {
|
|
string executable_filename = args.get<string>("executable", true);
|
|
string executable_data = phosg::load_file(executable_filename);
|
|
string decrypted;
|
|
if (args.get<bool>("simple")) {
|
|
string seed_str = args.get<string>("seed");
|
|
int64_t seed = seed_str.empty() ? -1 : stoull(seed_str, nullptr, 16);
|
|
decrypted = crypt_dp_address_jpn_simple(executable_data, seed);
|
|
} else {
|
|
string values_filename = args.get<string>("values", true);
|
|
string indexes_filename = args.get<string>("indexes", true);
|
|
string values_data = phosg::load_file(values_filename);
|
|
string indexes_data = phosg::load_file(indexes_filename);
|
|
decrypted = decrypt_dp_address_jpn(executable_data, values_data, indexes_data);
|
|
}
|
|
phosg::save_file(executable_filename + ".dec", decrypted);
|
|
});
|
|
Action a_encrypt_dcv2_executable(
|
|
"encrypt-dcv2-executable", "\
|
|
encrypt-dcv2-executable --executable=EXEC --indexes=INDEXES\n\
|
|
encrypt-dcv2-executable --executable=EXEC --simple --seed=SEED\n\
|
|
Encrypt a PSO DC v2 executable file. EXEC should be the path to the\n\
|
|
executable (DP_ADDRESS.JPN) and INDEXES should be the path to the index\n\
|
|
fixup table (KATSUO.SEA). The output is written to EXEC.enc and\n\
|
|
INDEXES.enc.\n\
|
|
If --simple is given, uses the simpler encryption method used in Ives\'\n\
|
|
Enhancement Pack. In this case, --seed is required.\n",
|
|
+[](phosg::Arguments& args) {
|
|
string executable_filename = args.get<string>("executable", true);
|
|
string executable_data = phosg::load_file(executable_filename);
|
|
string encrypted_executable;
|
|
if (args.get<bool>("simple")) {
|
|
int64_t seed = stoull(args.get<string>("seed", true), nullptr, 16);
|
|
encrypted_executable = crypt_dp_address_jpn_simple(executable_data, seed);
|
|
} else {
|
|
string indexes_filename = args.get<string>("indexes", true);
|
|
string indexes_data = phosg::load_file(indexes_filename);
|
|
auto encrypted = encrypt_dp_address_jpn(executable_data, indexes_data);
|
|
phosg::save_file(indexes_filename + ".enc", encrypted.indexes);
|
|
encrypted_executable = std::move(encrypted.executable);
|
|
}
|
|
phosg::save_file(executable_filename + ".enc", encrypted_executable);
|
|
});
|
|
|
|
Action a_decode_gci_snapshot(
|
|
"decode-gci-snapshot", "\
|
|
decode-gci-snapshot [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Decode a PSO GC snapshot file (in GCI format) into a Windows BMP image.\n",
|
|
+[](phosg::Arguments& args) {
|
|
auto data = read_input_data(args);
|
|
phosg::StringReader r(data);
|
|
const auto& header = r.get<PSOGCIFileHeader>();
|
|
try {
|
|
header.check();
|
|
} catch (const exception& e) {
|
|
phosg::log_warning_f("File header failed validation ({})", e.what());
|
|
}
|
|
const auto& file = r.get<PSOGCSnapshotFile>();
|
|
if (!file.checksum_correct()) {
|
|
phosg::log_warning_f("File internal checksum is incorrect");
|
|
}
|
|
|
|
auto img = file.decode_image();
|
|
string saved = img.serialize(phosg::ImageFormat::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",
|
|
+[](phosg::Arguments& args) {
|
|
const string& input_filename = args.get<string>(1, false);
|
|
string data;
|
|
if (!input_filename.empty() && (input_filename != "-")) {
|
|
data = phosg::load_file(input_filename);
|
|
} else {
|
|
data = phosg::read_all(stdin);
|
|
}
|
|
auto img = phosg::ImageRGBA8888N::from_file_data(data);
|
|
// If the image has any transparent pixels at all, use RGB5A3
|
|
string encoded = encode_gvm(
|
|
img, has_any_transparent_pixels(img) ? GVRDataFormat::RGB5A3 : GVRDataFormat::RGB565, "image.gvr", 0);
|
|
write_output_data(args, encoded.data(), encoded.size(), "gvm");
|
|
});
|
|
|
|
Action a_decode_bitmap_font(
|
|
"decode-bitmap-font", "\
|
|
decode-bitmap-font --width=WIDTH [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Decode a 2-bit bitmap font file (.fon) into a BMP image. The --width\n\
|
|
option is required; if the output looks wrong, try increasing or\n\
|
|
decreasing this number. For S18all04.fon, the width should be 20. If\n\
|
|
--show-unused is given, highlights the unused ares of ISO8859 characters\n\
|
|
in red.\n",
|
|
+[](phosg::Arguments& args) {
|
|
std::string data = read_input_data(args);
|
|
size_t width = args.get<size_t>("width");
|
|
phosg::Image res = decode_fon(data, width);
|
|
if (width == 20 && args.get<bool>("show-unused")) {
|
|
static const array<uint8_t, 0xBF> iso8859_widths{7, 9, 13, 11, 15, 14, 7, 8, 8, 11, 11, 7, 11, 7, 11, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 7, 7, 9, 11, 9, 10, 15, 13, 12, 13, 12, 11, 11, 13, 12, 8, 11, 12, 11, 15, 12, 13, 11, 13, 12, 11, 13, 12, 13, 15, 12, 13, 11, 8, 11, 8, 8, 9, 8, 12, 11, 12, 11, 12, 10, 12, 11, 6, 9, 11, 6, 14, 11, 12, 11, 11, 9, 11, 10, 11, 12, 15, 11, 11, 11, 9, 8, 9, 9, 9, 12, 7, 10, 13, 10, 10, 7, 10, 8, 17, 9, 12, 11, 9, 17, 9, 7, 11, 8, 8, 8, 11, 11, 8, 7, 6, 9, 12, 13, 13, 13, 10, 13, 13, 13, 13, 13, 13, 17, 13, 11, 11, 11, 11, 8, 8, 8, 8, 12, 12, 13, 13, 13, 13, 13, 11, 13, 12, 12, 12, 12, 15, 11, 10, 12, 12, 12, 12, 12, 12, 17, 12, 12, 12, 12, 12, 6, 6, 6, 6, 11, 11, 12, 12, 12, 12, 12, 11, 12, 11, 11, 11, 11, 11, 11, 11};
|
|
for (size_t z = 0; z < iso8859_widths.size(); z++) {
|
|
for (size_t y = (z + 1) * 0x12; y < (z + 2) * 0x12; y++) {
|
|
for (size_t x = iso8859_widths.at(z); x < width; x++) {
|
|
res.write(x, y, 0xFF0000FF);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
string bmp_data = res.serialize(phosg::ImageFormat::WINDOWS_BITMAP);
|
|
write_output_data(args, bmp_data.data(), bmp_data.size(), "bmp");
|
|
});
|
|
Action a_encode_bitmap_font(
|
|
"encode-bitmap-font", "\
|
|
encode-bitmap-font [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Encode an image in BMP or PPM/PNM format into a bitmap font file for use\n\
|
|
with the console PSO versions. The image dimensions must match the\n\
|
|
original fon\'s dimensions.\n",
|
|
+[](phosg::Arguments& args) {
|
|
const string& input_filename = args.get<string>(1, false);
|
|
string data;
|
|
if (!input_filename.empty() && (input_filename != "-")) {
|
|
data = phosg::load_file(input_filename);
|
|
} else {
|
|
data = phosg::read_all(stdin);
|
|
}
|
|
auto img = phosg::ImageRGB888::from_file_data(data);
|
|
string encoded = encode_fon(img);
|
|
write_output_data(args, encoded.data(), encoded.size(), "fon");
|
|
});
|
|
|
|
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",
|
|
+[](phosg::Arguments& args) {
|
|
bool round2 = args.get<bool>("round2");
|
|
bool exhaustive = args.get<bool>("exhaustive");
|
|
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 = phosg::load_file(system_filename);
|
|
phosg::StringReader r(system_data);
|
|
const auto& header = r.get<PSOGCIFileHeader>();
|
|
header.check();
|
|
const auto& system = r.get<PSOGCSystemFile>();
|
|
likely_round1_seed = system.creation_timestamp;
|
|
phosg::log_info_f("System file appears to be in order; round1 seed is {:08X}", likely_round1_seed);
|
|
} catch (const exception& e) {
|
|
phosg::log_warning_f("Cannot parse system file ({}); ignoring it", e.what());
|
|
}
|
|
} else if (!seed.empty()) {
|
|
likely_round1_seed = stoul(seed, nullptr, 16);
|
|
phosg::log_info_f("Specified round1 seed is {:08X}", 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);
|
|
phosg::StringReader r(data);
|
|
const auto& header = r.get<PSOGCIFileHeader>();
|
|
header.check();
|
|
|
|
const void* data_section = r.getv(header.data_size);
|
|
|
|
string round1_decrypted;
|
|
if (round2) {
|
|
round1_decrypted = decrypt_data_section<true>(data_section, header.data_size, likely_round1_seed, 0);
|
|
if (bytes > 0) {
|
|
round1_decrypted.resize(bytes);
|
|
}
|
|
}
|
|
|
|
auto process_file = [&]<typename StructT>() {
|
|
vector<multimap<size_t, uint32_t>> top_seeds_by_thread(
|
|
num_threads ? num_threads : thread::hardware_concurrency());
|
|
auto add_top_seed = +[](multimap<size_t, uint32_t>& top_seeds, uint32_t seed, size_t zero_count) -> void {
|
|
if (top_seeds.size() < 10 || (zero_count >= top_seeds.begin()->first)) {
|
|
top_seeds.emplace(zero_count, seed);
|
|
if (top_seeds.size() > 10) {
|
|
top_seeds.erase(top_seeds.begin());
|
|
}
|
|
}
|
|
};
|
|
auto merge_top_seeds = +[](const vector<multimap<size_t, uint32_t>>& top_seeds_by_thread) -> multimap<size_t, uint32_t> {
|
|
multimap<size_t, uint32_t> ret;
|
|
for (const auto& thread_top_seeds : top_seeds_by_thread) {
|
|
for (const auto& it : thread_top_seeds) {
|
|
ret.emplace(it.first, it.second);
|
|
}
|
|
}
|
|
return ret;
|
|
};
|
|
auto print_top_seeds = [&](const multimap<size_t, uint32_t>& top_seeds) -> void {
|
|
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)"
|
|
: "";
|
|
phosg::log_info_f("Round {} seed {:08X} resulted in {} zero bytes{}",
|
|
round2 ? '2' : '1', it.second, it.first, sys_seed_str);
|
|
}
|
|
};
|
|
|
|
uint32_t round2_lower_half = 0;
|
|
auto try_round2_seed = [&](uint64_t seed, size_t thread_num) -> bool {
|
|
seed |= round2_lower_half;
|
|
string decrypted = round1_decrypted;
|
|
PSOV2Encryption(seed).encrypt_big_endian(decrypted.data(), decrypted.size());
|
|
size_t zero_count = phosg::count_zeroes(decrypted.data() + offset, decrypted.size() - offset, stride);
|
|
add_top_seed(top_seeds_by_thread[thread_num], seed, zero_count);
|
|
return false;
|
|
};
|
|
|
|
if (!round2) {
|
|
phosg::parallel_blocks<uint64_t>(
|
|
[&](uint64_t seed, size_t thread_num) -> bool {
|
|
auto decrypted = decrypt_fixed_size_data_section_t<StructT, true>(
|
|
data_section, header.data_size, seed, true);
|
|
size_t zero_count = phosg::count_zeroes(
|
|
reinterpret_cast<const uint8_t*>(&decrypted) + offset,
|
|
sizeof(decrypted) - offset,
|
|
stride);
|
|
add_top_seed(top_seeds_by_thread[thread_num], seed, zero_count);
|
|
return false;
|
|
},
|
|
0, 0x100000000, 0x1000, num_threads);
|
|
|
|
} else if (!exhaustive) {
|
|
// The pseudorandom number generator used by PSO to encrypt its save files has a weakness: if the low bits of
|
|
// the seed are correct, the low bits of each 32-bit integer in the plaintext will also be correct, even if
|
|
// the high bits of the seed are wrong. Using this, we can brute-force the low half of the seed, then the
|
|
// high half, which is much faster than trying all possible seeds. Unfortunately, this relies on the
|
|
// distribution of values in the plaintext, so it only works for the round-2 seed - the decrypted data after
|
|
// round 1 is still essentially random.
|
|
phosg::parallel_blocks<uint64_t>(try_round2_seed, 0, 0x100000, 0x1000, num_threads);
|
|
auto intermediate_top_seeds = merge_top_seeds(top_seeds_by_thread);
|
|
if (intermediate_top_seeds.empty()) {
|
|
throw logic_error("no intermediate seeds were found");
|
|
}
|
|
print_top_seeds(intermediate_top_seeds);
|
|
round2_lower_half = intermediate_top_seeds.rbegin()->second & 0xFFFF;
|
|
phosg::log_info_f("Lower half of seed is likely {:04X} ({} zero bytes)", round2_lower_half, intermediate_top_seeds.rbegin()->first);
|
|
for (auto& top_seeds : top_seeds_by_thread) {
|
|
top_seeds.clear();
|
|
}
|
|
phosg::parallel_blocks<uint64_t>(
|
|
[&](uint64_t seed, size_t thread_num) -> bool {
|
|
return try_round2_seed((seed << 16) | round2_lower_half, thread_num);
|
|
},
|
|
0, 0x10000, 0x80, num_threads);
|
|
|
|
} else {
|
|
// The user requested not to take any shortcuts, so burn a lot of CPU power
|
|
phosg::parallel_blocks<uint64_t>(try_round2_seed, 0, 0x100000000, 0x1000, num_threads);
|
|
}
|
|
|
|
print_top_seeds(merge_top_seeds(top_seeds_by_thread));
|
|
};
|
|
|
|
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",
|
|
+[](phosg::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 = phosg::parse_data_string(plaintext_ascii, &mask, phosg::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 = phosg::parse_data_string(ciphertext_ascii, nullptr, phosg::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 = phosg::parallel_blocks<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, 0x1000, num_threads);
|
|
|
|
if (seed < 0x100000000) {
|
|
phosg::log_info_f("Found seed {:08X}", seed);
|
|
} else {
|
|
phosg::log_error_f("No seed found");
|
|
}
|
|
});
|
|
|
|
Action a_decode_gci(
|
|
"decode-gci", nullptr, +[](phosg::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);
|
|
phosg::save_file(input_filename + ".dec", decoded);
|
|
});
|
|
Action a_decode_vms(
|
|
"decode-vms", nullptr, +[](phosg::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);
|
|
phosg::save_file(input_filename + ".dec", decoded);
|
|
});
|
|
Action a_decode_dlq(
|
|
"decode-dlq", nullptr, +[](phosg::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));
|
|
phosg::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 [OPTIONS...]\n\
|
|
decode-qst INPUT-FILENAME [OPTIONS...]\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",
|
|
+[](phosg::Arguments& args) {
|
|
string input_filename = args.get<string>(1, false);
|
|
if (input_filename.empty() || (input_filename == "-")) {
|
|
throw invalid_argument("an input filename is required");
|
|
}
|
|
bool decompress = args.get<bool>("decompress");
|
|
auto files = decode_qst_data(read_input_data(args));
|
|
for (const auto& it : files) {
|
|
phosg::save_file(
|
|
input_filename + "-" + it.first + (decompress ? "d" : ""),
|
|
(decompress ? prs_decompress(it.second) : 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",
|
|
+[](phosg::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 = bin_filename.ends_with(".bin")
|
|
? (bin_filename.substr(0, bin_filename.size() - 3) + "dat")
|
|
: (bin_filename + ".dat");
|
|
string pvr_filename = bin_filename.ends_with(".bin")
|
|
? (bin_filename.substr(0, bin_filename.size() - 3) + "pvr")
|
|
: (bin_filename + ".pvr");
|
|
auto bin_data = make_shared<string>(phosg::load_file(bin_filename));
|
|
auto dat_data = make_shared<string>(phosg::load_file(dat_filename));
|
|
shared_ptr<string> pvr_data;
|
|
try {
|
|
pvr_data = make_shared<string>(phosg::load_file(pvr_filename));
|
|
} catch (const phosg::cannot_open_file&) {
|
|
}
|
|
|
|
auto vq = make_shared<VersionedQuest>();
|
|
vq->meta.version = version;
|
|
vq->bin_contents = bin_data;
|
|
vq->dat_contents = dat_data;
|
|
vq->pvr_contents = 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 [OPTIONS] [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,\n\
|
|
--gc-ep3, --xb, or --bb options. Other options:\n\
|
|
--qedit: newserv uses more descriptive opcode mnemonics by default; this\n\
|
|
option will result in names matching those used by QEdit.\n\
|
|
--reassembly: If you intend to reassemble the script after editing it,\n\
|
|
use this option to add explicit label numbers and remove offsets and\n\
|
|
data in code sections.\n\
|
|
--map-file=FILENAME: Include references to script labels from this map.\n\
|
|
--language=L: Decode strings using this language. L may be J, E, G, F,\n\
|
|
S, B, T, or K, for Japanese, English, German, French, Spanish,\n\
|
|
Simplified Chinese, Traditional Chinese, or Korean respectively.\n",
|
|
+[](phosg::Arguments& args) {
|
|
string data = read_input_data(args);
|
|
auto version = get_cli_version(args);
|
|
if (!args.get<bool>("decompressed")) {
|
|
data = prs_decompress(data);
|
|
}
|
|
shared_ptr<MapFile> map_file;
|
|
string map_filename = args.get<string>("map-file", false);
|
|
if (!map_filename.empty()) {
|
|
auto map_data = make_shared<string>(prs_decompress(phosg::load_file(map_filename)));
|
|
map_file = make_shared<MapFile>(map_data);
|
|
}
|
|
const auto& language_str = args.get<string>("language");
|
|
Language language = language_str.empty() ? Language::ENGLISH : language_for_name(language_str);
|
|
bool reassembly_mode = args.get<bool>("reassembly");
|
|
bool use_qedit_names = args.get<bool>("qedit");
|
|
string result = disassemble_quest_script(
|
|
data.data(), data.size(), version, language, map_file, reassembly_mode, use_qedit_names);
|
|
write_output_data(args, result.data(), result.size(), "txt");
|
|
});
|
|
Action a_disassemble_quest_map(
|
|
"disassemble-quest-map", "\
|
|
disassemble-quest-map [OPTIONS] [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Disassemble the input quest map (.dat file) into a text representation of\n\
|
|
the data it contains. If --decompressed is given, don\'t decompress before\n\
|
|
disassembling.\n",
|
|
+[](phosg::Arguments& args) {
|
|
auto data = make_shared<string>(read_input_data(args));
|
|
if (!args.get<bool>("decompressed")) {
|
|
*data = prs_decompress(*data);
|
|
}
|
|
bool reassembly = args.get<bool>("reassembly");
|
|
string result = MapFile(data).disassemble(reassembly, get_cli_version(args, Version::UNKNOWN));
|
|
write_output_data(args, result.data(), result.size(), "txt");
|
|
});
|
|
Action a_disassemble_free_map(
|
|
"disassemble-free-map", "\
|
|
disassemble-free-map INPUT-FILENAME [OUTPUT-FILENAME]\n\
|
|
Disassemble the input free-play map (.dat or .evt file) into a text\n\
|
|
representation of the data it contains. Unlike other disassembly actions,\n\
|
|
this action expects its input to be already decompressed. If the input is\n\
|
|
compressed, use the --compressed option. Also unlike other options, the\n\
|
|
input must be from a file (that is, INPUT-FILENAME is required and cannot\n\
|
|
be \"-\").\n",
|
|
+[](phosg::Arguments& args) {
|
|
const string& input_filename = args.get<string>(1, true);
|
|
string input_filename_lower = phosg::tolower(input_filename);
|
|
bool is_events = input_filename_lower.ends_with(".evt");
|
|
bool is_enemies = input_filename_lower.ends_with("e.dat") || input_filename_lower.ends_with("e_s.dat") || input_filename_lower.ends_with("e_c1.dat") || input_filename_lower.ends_with("e_d.dat");
|
|
bool is_objects = input_filename_lower.ends_with("o.dat") || input_filename_lower.ends_with("o_s.dat") || input_filename_lower.ends_with("o_c1.dat") || input_filename_lower.ends_with("o_d.dat");
|
|
if (!is_objects && !is_enemies && !is_events) {
|
|
throw runtime_error("cannot determine input file type");
|
|
}
|
|
|
|
auto data = make_shared<string>(read_input_data(args));
|
|
if (args.get<bool>("compressed")) {
|
|
*data = prs_decompress(*data);
|
|
}
|
|
|
|
uint8_t floor = args.get<uint8_t>("floor", 0);
|
|
bool reassembly = args.get<bool>("reassembly");
|
|
string result;
|
|
if (is_objects) {
|
|
result = MapFile(floor, data, nullptr, nullptr).disassemble(reassembly, get_cli_version(args, Version::UNKNOWN));
|
|
} else if (is_enemies) {
|
|
result = MapFile(floor, nullptr, data, nullptr).disassemble(reassembly, get_cli_version(args, Version::UNKNOWN));
|
|
} else if (is_events) {
|
|
result = MapFile(floor, nullptr, nullptr, data).disassemble(reassembly, get_cli_version(args, Version::UNKNOWN));
|
|
} else {
|
|
throw logic_error("unhandled input type");
|
|
}
|
|
result.push_back('\n');
|
|
|
|
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",
|
|
+[](phosg::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_assemble_quest_script(
|
|
"assemble-quest-script", "\
|
|
assemble-quest-script [OPTIONS] [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. If --disable-strict is given, allows some\n\
|
|
invalid behaviors (e.g. calling an undefined label by number).\n",
|
|
+[](phosg::Arguments& args) {
|
|
string text = read_input_data(args);
|
|
|
|
const string& input_filename = args.get<string>(1, false);
|
|
string include_dir = (!input_filename.empty() && (input_filename != "-"))
|
|
? phosg::dirname(input_filename)
|
|
: ".";
|
|
|
|
auto result = assemble_quest_script(
|
|
text,
|
|
{include_dir, "system/quests/includes"},
|
|
{include_dir, "system/quests/includes", "system/client-functions/System"},
|
|
!args.get<bool>("disable-strict"));
|
|
string result_data = std::move(result.data);
|
|
bool compress = !args.get<bool>("decompressed");
|
|
if (compress) {
|
|
if (args.get<bool>("optimal")) {
|
|
result_data = prs_compress_optimal(result_data);
|
|
} else {
|
|
result_data = prs_compress(result_data);
|
|
}
|
|
}
|
|
write_output_data(args, result_data.data(), result_data.size(), compress ? "bin" : "bind");
|
|
});
|
|
|
|
Action a_assemble_all_client_functions(
|
|
"assemble-all-client-functions", "\
|
|
assemble-all-client-functions [--skip-encrypted] OUTPUT-DIRECTORY\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. If --skip-encrypted is given, only the unencrypted .bin files are\n\
|
|
created.\n",
|
|
+[](phosg::Arguments& args) {
|
|
auto fci = make_shared<ClientFunctionIndex>("system/client-functions", false);
|
|
|
|
const std::string& output_dir = args.get<string>(1);
|
|
std::filesystem::create_directories(output_dir);
|
|
|
|
bool skip_encrypted = args.get<bool>("skip-encrypted");
|
|
auto process_code = [&](shared_ptr<const ClientFunctionIndex::Function> code,
|
|
uint32_t checksum_addr,
|
|
uint32_t checksum_size,
|
|
uint32_t override_start_addr) -> void {
|
|
for (uint8_t encrypted = 0; encrypted < 2; encrypted++) {
|
|
if (encrypted && skip_encrypted) {
|
|
continue;
|
|
}
|
|
phosg::StringWriter w;
|
|
string data = prepare_send_function_call_data(
|
|
code, {}, nullptr, 0, checksum_addr, checksum_size, override_start_addr, encrypted);
|
|
w.put(PSOCommandHeaderDCV3{.command = 0xB2, .flag = 0x00, .size = data.size() + 4});
|
|
w.write(data);
|
|
string out_path = std::format("{}/{}.{}.{}.bin",
|
|
output_dir, code->short_name, str_for_specific_version(code->specific_version), (encrypted ? "enc" : "std"));
|
|
phosg::save_file(out_path, w.str());
|
|
phosg::fwrite_fmt(stderr, "... {}\n", out_path);
|
|
}
|
|
};
|
|
|
|
for (const auto& [_, fn] : fci->all_functions) {
|
|
process_code(fn, 0, 0, 0);
|
|
}
|
|
try {
|
|
process_code(fci->get("CacheClearFix-Phase1", SPECIFIC_VERSION_PPC_INDETERMINATE), 0x80000000, 8, 0x7F2734EC);
|
|
} catch (const out_of_range&) {
|
|
}
|
|
});
|
|
|
|
Action a_generate_gsl(
|
|
"generate-gsl", "\
|
|
generate-gsl INPUT-DIRECTORY [OUTPUT-FILENAME] [--big-endian]\n\
|
|
Generate a .gsl archive from the contents of a directory. If --big-endian\n\
|
|
is given, the archive header is generated in GameCube format; otherwise it\n\
|
|
is generated in PC/BB format.\n",
|
|
+[](phosg::Arguments& args) {
|
|
string input_directory = args.get<string>(1, true);
|
|
string output_filename = args.get<string>(2, false);
|
|
if (output_filename.empty()) {
|
|
output_filename = input_directory;
|
|
while (output_filename.ends_with("/")) {
|
|
output_filename.pop_back();
|
|
}
|
|
output_filename += ".gsl";
|
|
}
|
|
|
|
unordered_map<string, string> files;
|
|
for (const auto& item : std::filesystem::directory_iterator(input_directory)) {
|
|
string filename = item.path().filename().string();
|
|
string file_path = input_directory + "/" + filename;
|
|
if (!std::filesystem::is_regular_file(file_path)) {
|
|
throw std::runtime_error(std::format(
|
|
"input directory contains {} which is not a file", filename));
|
|
}
|
|
files.emplace(filename, phosg::load_file(file_path));
|
|
}
|
|
string gsl_contents = GSLArchive::generate(files, args.get<bool>("big-endian"));
|
|
phosg::save_file(output_filename, gsl_contents);
|
|
});
|
|
|
|
void a_extract_archive_fn(phosg::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 == "-") {
|
|
output_prefix = "./";
|
|
} else {
|
|
output_prefix += ".out/";
|
|
}
|
|
} else if (!output_prefix.ends_with("/")) {
|
|
output_prefix += "/";
|
|
}
|
|
|
|
std::filesystem::create_directories(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 = std::format("{}-{}", output_prefix, z);
|
|
phosg::save_file(out_file, e.first, e.second);
|
|
phosg::fwrite_fmt(stderr, "... {}\n", out_file);
|
|
}
|
|
} 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;
|
|
phosg::save_file(out_file, e.first, e.second);
|
|
phosg::fwrite_fmt(stderr, "... {}\n", out_file);
|
|
}
|
|
} 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;
|
|
phosg::save_file(out_file, data);
|
|
phosg::fwrite_fmt(stderr, "... {}\n", out_file);
|
|
}
|
|
|
|
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";
|
|
phosg::save_file(out_file, data);
|
|
phosg::fwrite_fmt(stderr, "... {}\n", out_file);
|
|
}
|
|
}
|
|
} else if (args.get<string>(0) == "extract-ppk") {
|
|
auto files = decode_ppk_file(*data_shared, args.get<string>("password", true));
|
|
for (const auto& [filename, data] : files) {
|
|
string out_file = output_prefix + filename;
|
|
phosg::save_file(out_file, data);
|
|
phosg::fwrite_fmt(stderr, "... {}\n", out_file);
|
|
}
|
|
} 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", nullptr, a_extract_archive_fn);
|
|
Action a_extract_ppk("extract-ppk", "\
|
|
extract-afs [INPUT-FILENAME [OUTPUT-DIRECTORY]] [OPTIONS]\n\
|
|
extract-gsl [INPUT-FILENAME [OUTPUT-DIRECTORY]] [OPTIONS]\n\
|
|
extract-bml [INPUT-FILENAME [OUTPUT-DIRECTORY]] [OPTIONS]\n\
|
|
extract-ppk [INPUT-FILENAME [OUTPUT-DIRECTORY]] [OPTIONS]\n\
|
|
Extract all files from an AFS, GSL, BML, or PPK archive into the current\n\
|
|
directory. input-filename may be specified. If OUTPUT-DIRECTORY is\n\
|
|
specified, files are written to that directory; if not, then they are\n\
|
|
written to the directory INPUT-FILENAME.out. If --big-endian is given,\n\
|
|
the archive header is read in GameCube format; otherwise it is read in\n\
|
|
PC/BB format. For PPK archives, the --password= option is required.\n",
|
|
a_extract_archive_fn);
|
|
|
|
Action a_transcode_text("transcode-text", nullptr, +[](phosg::Arguments& args) {
|
|
TextTranscoder* tt_from = nullptr;
|
|
{
|
|
std::string from_name = args.get<std::string>("from");
|
|
if (from_name == "8859" || from_name == "iso8859") {
|
|
tt_from = &tt_8859_to_utf8;
|
|
} else if (from_name == "sjis") {
|
|
tt_from = &tt_sega_sjis_to_utf8;
|
|
} else if (from_name == "utf16") {
|
|
tt_from = &tt_utf16_to_utf8;
|
|
} else if (from_name == "ascii") {
|
|
tt_from = &tt_ascii_to_utf8;
|
|
} else if (from_name != "utf8") {
|
|
throw std::invalid_argument("invalid value for --from: " + from_name);
|
|
}
|
|
}
|
|
|
|
TextTranscoder* tt_to = nullptr;
|
|
{
|
|
std::string to_name = args.get<std::string>("to");
|
|
if (to_name == "8859" || to_name == "iso8859") {
|
|
tt_to = &tt_utf8_to_8859;
|
|
} else if (to_name == "sjis") {
|
|
tt_to = &tt_utf8_to_sega_sjis;
|
|
} else if (to_name == "utf16") {
|
|
tt_to = &tt_utf8_to_utf16;
|
|
} else if (to_name == "ascii") {
|
|
tt_to = &tt_utf8_to_ascii;
|
|
} else if (to_name != "utf8") {
|
|
throw std::invalid_argument("invalid value for --to: " + to_name);
|
|
}
|
|
}
|
|
|
|
string data = read_input_data(args);
|
|
if (tt_from) {
|
|
data = (*tt_from)(data);
|
|
}
|
|
if (tt_to) {
|
|
data = (*tt_to)(data);
|
|
}
|
|
write_output_data(args, data.data(), data.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 (this is needed for DC\n\
|
|
NTE and 11/2000). If --has-pr3 is given, expects the input not to have a\n\
|
|
REL footer.\n",
|
|
+[](phosg::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);
|
|
}
|
|
phosg::JSON j = ts->json();
|
|
string out_data = j.serialize(phosg::JSON::SerializeOption::FORMAT | phosg::JSON::SerializeOption::ESCAPE_CONTROLS_ONLY | phosg::JSON::SerializeOption::EXPAND_LEAF_CONTAINERS);
|
|
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",
|
|
+[](phosg::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 = phosg::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");
|
|
}
|
|
phosg::save_file(std::format("{}.pr2", input_filename), result.first);
|
|
phosg::save_file(std::format("{}.pr3", input_filename), result.second);
|
|
} else if (output_filename == "-") {
|
|
throw runtime_error("encoded text archive cannot be written to stdout");
|
|
} else {
|
|
string out_filename = output_filename;
|
|
if (out_filename.ends_with(".pr2")) {
|
|
phosg::save_file(out_filename, result.first);
|
|
out_filename[out_filename.size() - 1] = '3';
|
|
phosg::save_file(out_filename, result.second);
|
|
} else {
|
|
phosg::save_file(out_filename + ".pr2", result.first);
|
|
phosg::save_file(out_filename + ".pr3", result.second);
|
|
}
|
|
}
|
|
});
|
|
|
|
Action a_decode_unicode_text_set(
|
|
"decode-unicode-text-set", nullptr, +[](phosg::Arguments& args) {
|
|
UnicodeTextSet uts(read_input_data(args));
|
|
phosg::JSON j = uts.json();
|
|
string out_data = j.serialize(phosg::JSON::SerializeOption::FORMAT | phosg::JSON::SerializeOption::ESCAPE_CONTROLS_ONLY | phosg::JSON::SerializeOption::EXPAND_LEAF_CONTAINERS);
|
|
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",
|
|
+[](phosg::Arguments& args) {
|
|
UnicodeTextSet uts(phosg::JSON::parse(read_input_data(args)));
|
|
string encoded = uts.serialize();
|
|
write_output_data(args, encoded.data(), encoded.size(), "prs");
|
|
});
|
|
|
|
Action a_decode_credits_text_archive(
|
|
"decode-credits-text-archive", "\
|
|
decode-credits-text-archive [OPTIONS] [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Decode a credits text archive (AdEnding.rel) to JSON. Use the --big-endian\n\
|
|
option if the file is for PSO GC.\n",
|
|
+[](phosg::Arguments& args) {
|
|
auto ret = decode_credits_text_set(read_input_data(args), args.get<bool>("big-endian"));
|
|
auto json = phosg::JSON::list();
|
|
for (const auto& it : ret) {
|
|
json.emplace_back(it);
|
|
}
|
|
string out_data = json.serialize(phosg::JSON::SerializeOption::FORMAT | phosg::JSON::SerializeOption::ESCAPE_CONTROLS_ONLY | phosg::JSON::SerializeOption::EXPAND_LEAF_CONTAINERS);
|
|
write_output_data(args, out_data.data(), out_data.size(), "json");
|
|
});
|
|
Action a_encode_credits_text_archive(
|
|
"encode-credits-text-archive", "\
|
|
encode-credits-text-archive [OPTIONS] [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Encode a credits text archive (AdEnding.rel) from JSON. Use the\n\
|
|
--big-endian option if the file is for PSO GC.\n",
|
|
+[](phosg::Arguments& args) {
|
|
auto json = phosg::JSON::parse(read_input_data(args));
|
|
std::vector<std::string> data;
|
|
for (const auto& it : json.as_list()) {
|
|
data.emplace_back(it->as_string());
|
|
}
|
|
auto ret = encode_credits_text_set(data, args.get<bool>("big-endian"));
|
|
write_output_data(args, ret.data(), ret.size(), "rel");
|
|
});
|
|
|
|
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",
|
|
+[](phosg::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 (unitxt_filename.ends_with(".prs")) {
|
|
uts = make_unique<UnicodeTextSet>(phosg::load_file(unitxt_filename));
|
|
} else if (unitxt_filename.ends_with(".json")) {
|
|
uts = make_unique<UnicodeTextSet>(phosg::JSON::parse(phosg::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",
|
|
+[](phosg::Arguments& args) {
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->load_patch_indexes();
|
|
s->load_text_index();
|
|
s->load_word_select_table();
|
|
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_download_files(
|
|
"download-files", nullptr,
|
|
+[](phosg::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>(
|
|
phosg::load_object_file<PSOBBEncryption::KeyFile>("system/blueburst/keys/" + key_file_name + ".nsk"));
|
|
}
|
|
auto [remote_host, remote_port] = phosg::parse_netloc(args.get<string>(1));
|
|
|
|
auto io_context = make_shared<asio::io_context>();
|
|
unique_ptr<DownloadSession> download_session;
|
|
unique_ptr<PatchDownloadSession> patch_download_session;
|
|
if (is_patch(version)) {
|
|
patch_download_session = std::make_unique<PatchDownloadSession>(
|
|
io_context,
|
|
remote_host,
|
|
remote_port,
|
|
args.get<string>("output-dir", true),
|
|
version,
|
|
args.get<string>("username", false),
|
|
args.get<string>("password", false),
|
|
args.get<string>("email", false),
|
|
args.get<bool>("show-command-data"));
|
|
asio::co_spawn(*io_context, patch_download_session->run(), asio::detached);
|
|
|
|
} else {
|
|
auto character = PSOCHARFile::load_shared(args.get<string>("character", true), false).character_file;
|
|
auto ship_menu_selections_str = args.get<string>("ship-menu-selections", false);
|
|
|
|
unordered_set<string> ship_menu_selections;
|
|
if (!ship_menu_selections_str.empty()) {
|
|
for (const string& s : phosg::split(ship_menu_selections_str, ',')) {
|
|
ship_menu_selections.emplace(s);
|
|
}
|
|
}
|
|
|
|
vector<string> on_request_complete_commands;
|
|
string on_request_complete_arg = args.get<string>("on-request-complete-command", false);
|
|
if (!on_request_complete_arg.empty()) {
|
|
for (const string& command : phosg::split(on_request_complete_arg, ',')) {
|
|
on_request_complete_commands.emplace_back(phosg::parse_data_string(command));
|
|
}
|
|
}
|
|
|
|
uint32_t serial_number = args.get<uint32_t>(
|
|
"serial-number",
|
|
0,
|
|
is_v1_or_v2(version) ? phosg::Arguments::IntFormat::HEX : phosg::Arguments::IntFormat::DEFAULT);
|
|
|
|
download_session = std::make_unique<DownloadSession>(
|
|
io_context,
|
|
remote_host,
|
|
remote_port,
|
|
args.get<string>("output-dir", true),
|
|
version,
|
|
static_cast<Language>(args.get<uint8_t>("language")),
|
|
key,
|
|
phosg::random_object<uint32_t>(),
|
|
serial_number,
|
|
args.get<string>("access-key", false),
|
|
args.get<string>("username", false),
|
|
args.get<string>("password", false),
|
|
args.get<string>("xb-gamertag", false),
|
|
args.get<uint64_t>("xb-user-id", 0, phosg::Arguments::IntFormat::HEX),
|
|
args.get<uint64_t>("xb-account-id", 0, phosg::Arguments::IntFormat::HEX),
|
|
character,
|
|
ship_menu_selections,
|
|
on_request_complete_commands,
|
|
args.get<bool>("interactive"),
|
|
args.get<bool>("show-command-data"));
|
|
asio::co_spawn(*io_context, download_session->run(), asio::detached);
|
|
}
|
|
io_context->run();
|
|
});
|
|
|
|
std::shared_ptr<RareItemSet> load_rare_item_set(
|
|
const std::string& filename, bool is_v1, std::shared_ptr<const ItemNameIndex> v4_item_name_index) {
|
|
string filename_lower = phosg::tolower(filename);
|
|
auto data = make_shared<string>(phosg::load_file(filename));
|
|
if (filename_lower.ends_with(".json")) {
|
|
return make_shared<RareItemSet>(phosg::JSON::parse(*data), v4_item_name_index);
|
|
} else if (filename_lower.ends_with(".gsl")) {
|
|
return make_shared<RareItemSet>(GSLArchive(data, false), false);
|
|
} else if (filename_lower.ends_with(".gslb")) {
|
|
return make_shared<RareItemSet>(GSLArchive(data, true), true);
|
|
} else if (filename_lower.ends_with(".afs")) {
|
|
return make_shared<RareItemSet>(AFSArchive(data), is_v1);
|
|
} else if (filename_lower.ends_with(".rel")) {
|
|
return make_shared<RareItemSet>(*data, true);
|
|
} else {
|
|
throw runtime_error("cannot determine input format; use a filename ending with .json, .gsl, .gslb, .afs, or .rel");
|
|
}
|
|
}
|
|
|
|
Action a_convert_rare_item_set(
|
|
"convert-rare-item-set", "\
|
|
convert-rare-item-set INPUT-FILENAME [OUTPUT-FILENAME] [OPTIONS]\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\
|
|
.html (HTML rare table; cannot be used in input filename)\n\
|
|
If the --multiply=X option is given, multiplies all drop rates by X (given\n\
|
|
as a decimal value). The HTML drop tables will account for each enemy\'s\n\
|
|
drop-anything rate; the true drop rates are shown in tooltips.\n",
|
|
+[](phosg::Arguments& args) {
|
|
double rate_factor = args.get<double>("multiply", 1.0);
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->load_config_early();
|
|
s->load_patch_indexes();
|
|
s->load_text_index();
|
|
s->load_item_definitions();
|
|
s->load_item_name_indexes();
|
|
s->load_drop_tables();
|
|
|
|
string input_filename = args.get<string>(1, false);
|
|
if (input_filename.empty() || (input_filename == "-")) {
|
|
throw runtime_error("input filename must be given");
|
|
}
|
|
auto rs = load_rare_item_set(
|
|
input_filename, is_v1(get_cli_version(args, Version::BB_V4)), s->item_name_index(Version::BB_V4));
|
|
if (rate_factor != 1.0) {
|
|
rs->multiply_all_rates(rate_factor);
|
|
}
|
|
|
|
string output_filename = args.get<string>(2, false);
|
|
string output_filename_lower = phosg::tolower(output_filename);
|
|
if (output_filename.empty() || (output_filename == "-")) {
|
|
rs->print_all_collections(stdout, s->item_name_index_opt(get_cli_version(args, Version::BB_V4)));
|
|
} else if (output_filename_lower.ends_with(".json")) {
|
|
auto json = rs->json(s->item_name_index_opt(get_cli_version(args, Version::BB_V4)));
|
|
string data = json.serialize(phosg::JSON::SerializeOption::FORMAT | phosg::JSON::SerializeOption::HEX_INTEGERS | phosg::JSON::SerializeOption::SORT_DICT_KEYS);
|
|
write_output_data(args, data.data(), data.size(), nullptr);
|
|
} else if (output_filename_lower.ends_with(".gsl")) {
|
|
string data = rs->serialize_gsl(args.get<bool>("big-endian"));
|
|
write_output_data(args, data.data(), data.size(), nullptr);
|
|
} else if (output_filename_lower.ends_with(".gslb")) {
|
|
string data = rs->serialize_gsl(true);
|
|
write_output_data(args, data.data(), data.size(), nullptr);
|
|
} else if (output_filename_lower.ends_with(".afs")) {
|
|
bool is_v1 = ::is_v1(get_cli_version(args, Version::DC_V2));
|
|
string data = rs->serialize_afs(is_v1);
|
|
write_output_data(args, data.data(), data.size(), nullptr);
|
|
} else if (output_filename_lower.ends_with(".html")) {
|
|
Version cli_version = get_cli_version(args, Version::BB_V4);
|
|
bool is_v1 = ::is_v1(cli_version);
|
|
for (GameMode mode : ALL_GAME_MODES_V4) {
|
|
for (Episode episode : ALL_EPISODES_V4) {
|
|
for (Difficulty difficulty : ALL_DIFFICULTIES_V234) {
|
|
if ((is_v1 && (difficulty == Difficulty::ULTIMATE)) || (!rs->has_entries_for_game_config(mode, episode, difficulty))) {
|
|
continue;
|
|
}
|
|
auto item_name_index = s->item_name_index(cli_version);
|
|
string data = rs->serialize_html(mode, episode, difficulty, item_name_index, s->common_item_set(cli_version, nullptr));
|
|
string out_filename = output_filename.substr(0, output_filename.size() - 5) + "." + name_for_mode(mode) + "." + abbreviation_for_episode(episode) + "." + abbreviation_for_difficulty(difficulty) + output_filename.substr(output_filename.size() - 5);
|
|
phosg::save_file(out_filename, data);
|
|
phosg::log_info_f("... {}", out_filename);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
throw runtime_error("cannot determine output format; use a filename ending with .json, .gsl, .gslb, or .afs");
|
|
}
|
|
});
|
|
Action a_compare_rare_item_set(
|
|
"compare-rare-item-set", nullptr,
|
|
+[](phosg::Arguments& args) {
|
|
string input_filename1 = args.get<string>(1, false);
|
|
if (input_filename1.empty() || (input_filename1 == "-")) {
|
|
throw runtime_error("two input filenames must be given");
|
|
}
|
|
string input_filename2 = args.get<string>(2, false);
|
|
if (input_filename2.empty() || (input_filename2 == "-")) {
|
|
throw runtime_error("two input filenames must be given");
|
|
}
|
|
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->load_config_early();
|
|
s->load_patch_indexes();
|
|
s->load_text_index();
|
|
s->load_item_definitions();
|
|
s->load_item_name_indexes();
|
|
s->load_drop_tables();
|
|
|
|
bool is_v1 = ::is_v1(get_cli_version(args, Version::BB_V4));
|
|
auto rs1 = load_rare_item_set(input_filename1, is_v1, s->item_name_index(Version::BB_V4));
|
|
auto rs2 = load_rare_item_set(input_filename2, is_v1, s->item_name_index(Version::BB_V4));
|
|
|
|
rs1->print_diff(stdout, *rs2);
|
|
});
|
|
|
|
static shared_ptr<CommonItemSet> load_common_item_set(
|
|
const std::string& filename, const std::string& ct_filename, bool big_endian) {
|
|
auto data = make_shared<string>(phosg::load_file(filename));
|
|
if (filename.ends_with(".json")) {
|
|
return make_shared<JSONCommonItemSet>(phosg::JSON::parse(*data));
|
|
} else if (filename.ends_with(".afs")) {
|
|
auto ct_data = make_shared<string>(phosg::load_file(ct_filename));
|
|
return make_shared<AFSV2CommonItemSet>(data, ct_data);
|
|
} else if (filename.ends_with(".gsl")) {
|
|
return make_shared<GSLV3V4CommonItemSet>(data, big_endian);
|
|
} else if (filename.ends_with(".gslb")) {
|
|
return make_shared<GSLV3V4CommonItemSet>(data, true);
|
|
} else {
|
|
throw runtime_error("cannot determine input format; use a filename ending with .json, .afs, .gsl, or .gslb");
|
|
}
|
|
}
|
|
|
|
Action a_convert_common_item_set(
|
|
"convert-common-item-set", "\
|
|
convert-common-item-set INPUT-FILENAME [OUTPUT-FILENAME]\n\
|
|
Convert the input rare item set to a JSON representation and write it to\n\
|
|
OUTPUT-FILENAME or stdout. The input filename must end in one of the\n\
|
|
following extensions:\n\
|
|
.json (newserv JSON common item table)\n\
|
|
.afs (PSO v2 AFS archive; --ct-filename is required in this case)\n\
|
|
.gsl (PSO BB little-endian GSL archive)\n\
|
|
.gslb (PSO GC big-endian GSL archive)\n\
|
|
Options:\n\
|
|
--ct-filename=FILENAME: Required if the input is an AFS archive.\n\
|
|
Specifies where to read the ItemCT (Challenge Mode) tables from.\n\
|
|
Should be another AFS file.\n\
|
|
--big-endian: If input is a GSL file, always decode it as big-endian\n\
|
|
even if the file extension is not gslb.\n",
|
|
+[](phosg::Arguments& args) {
|
|
string input_filename = args.get<string>(1, false);
|
|
if (input_filename.empty() || (input_filename == "-")) {
|
|
throw runtime_error("input filename must be given");
|
|
}
|
|
|
|
auto cs = load_common_item_set(input_filename, args.get<string>("ct-filename", false), args.get<bool>("big-endian"));
|
|
const string& output_filename = args.get<string>(2, false);
|
|
if (output_filename.empty()) {
|
|
cs->print(stdout);
|
|
} else {
|
|
auto json = cs->json();
|
|
string json_data = json.serialize(phosg::JSON::SerializeOption::FORMAT | phosg::JSON::SerializeOption::SORT_DICT_KEYS);
|
|
write_output_data(args, json_data.data(), json_data.size(), "json");
|
|
}
|
|
});
|
|
Action a_compare_common_item_set(
|
|
"compare-common-item-set", nullptr,
|
|
+[](phosg::Arguments& args) {
|
|
string input_filename1 = args.get<string>(1, false);
|
|
if (input_filename1.empty() || (input_filename1 == "-")) {
|
|
throw runtime_error("two input filenames must be given");
|
|
}
|
|
string input_filename2 = args.get<string>(2, false);
|
|
if (input_filename2.empty() || (input_filename2 == "-")) {
|
|
throw runtime_error("two input filenames must be given");
|
|
}
|
|
|
|
auto cs1 = load_common_item_set(input_filename1, args.get<string>("ct-filename1", false), args.get<bool>("big-endian1"));
|
|
auto cs2 = load_common_item_set(input_filename2, args.get<string>("ct-filename2", false), args.get<bool>("big-endian2"));
|
|
cs1->print_diff(stdout, *cs2);
|
|
});
|
|
|
|
Action a_decode_item_parameter_table(
|
|
"decode-item-parameter-table", "\
|
|
decode-item-parameter-table [INPUT-FILENAME [OUTPUT-FILENAME]] [OPTIONS...]\n\
|
|
Converts an ItemPMT file into a JSON item parameter table. A version\n\
|
|
option is required. Use --hex to make item codes in the output readable;\n\
|
|
however, this option also uses nonstandard JSON syntax - newserv can parse\n\
|
|
it, but many other JSON parsers can\'t. Expects compressed input (a .prs\n\
|
|
file) by default; use --decompressed if the input is not compressed.\n",
|
|
+[](phosg::Arguments& args) {
|
|
auto input_data = read_input_data(args);
|
|
if (!args.get<bool>("decompressed")) {
|
|
input_data = prs_decompress(input_data);
|
|
}
|
|
auto data = std::make_shared<string>(std::move(input_data));
|
|
auto pmt = ItemParameterTable::from_binary(data, get_cli_version(args, Version::BB_V4));
|
|
auto json = pmt->json();
|
|
uint32_t serialize_options = phosg::JSON::SerializeOption::FORMAT | phosg::JSON::SerializeOption::SORT_DICT_KEYS;
|
|
if (args.get<bool>("hex")) {
|
|
serialize_options |= phosg::JSON::SerializeOption::HEX_INTEGERS;
|
|
}
|
|
string json_data = json.serialize(serialize_options);
|
|
write_output_data(args, json_data.data(), json_data.size(), nullptr);
|
|
});
|
|
|
|
Action a_encode_item_parameter_table(
|
|
"encode-item-parameter-table", "\
|
|
encode-item-parameter-table [INPUT-FILENAME [OUTPUT-FILENAME]] [OPTIONS...]\n\
|
|
Converts a JSON item parameter table into an ItemPMT file compatible with\n\
|
|
the game client. A version option is required. By default the output will\n\
|
|
be compressed, as the client expects; use --decompressed to get\n\
|
|
uncompressed output.\n",
|
|
+[](phosg::Arguments& args) {
|
|
auto json = phosg::JSON::parse(read_input_data(args));
|
|
auto pmt = ItemParameterTable::from_json(json);
|
|
string data = pmt->serialize_binary(get_cli_version(args, Version::BB_V4));
|
|
if (!args.get<bool>("decompressed")) {
|
|
data = prs_compress_optimal(data);
|
|
}
|
|
write_output_data(args, data.data(), data.size(), nullptr);
|
|
});
|
|
|
|
Action a_decode_level_table(
|
|
"decode-level-table", nullptr,
|
|
+[](phosg::Arguments& args) {
|
|
auto input_data = read_input_data(args);
|
|
std::shared_ptr<LevelTable> table;
|
|
bool decompressed = args.get<bool>("decompressed");
|
|
switch (get_cli_version(args)) {
|
|
case Version::PC_V2:
|
|
table = std::make_shared<LevelTableV2>(decompressed ? input_data : prs_decompress(input_data));
|
|
break;
|
|
case Version::GC_V3:
|
|
table = std::make_shared<LevelTableGC>(
|
|
decompressed ? input_data : decrypt_and_decompress_pr2_data<true>(input_data));
|
|
break;
|
|
case Version::XB_V3:
|
|
table = std::make_shared<LevelTableXB>(
|
|
decompressed ? input_data : decrypt_and_decompress_pr2_data<false>(input_data));
|
|
break;
|
|
case Version::BB_V4:
|
|
table = std::make_shared<LevelTableV4>(decompressed ? input_data : prs_decompress(input_data));
|
|
break;
|
|
default:
|
|
throw std::runtime_error("This version does not have a level table");
|
|
}
|
|
auto json = table->json();
|
|
uint32_t serialize_options = phosg::JSON::SerializeOption::FORMAT | phosg::JSON::SerializeOption::SORT_DICT_KEYS;
|
|
if (args.get<bool>("hex")) {
|
|
serialize_options |= phosg::JSON::SerializeOption::HEX_INTEGERS;
|
|
}
|
|
string json_data = json.serialize(serialize_options);
|
|
write_output_data(args, json_data.data(), json_data.size(), nullptr);
|
|
});
|
|
|
|
Action a_encode_level_table(
|
|
"encode-level-table-v4", nullptr,
|
|
+[](phosg::Arguments& args) {
|
|
JSONLevelTable table(phosg::JSON::parse(read_input_data(args)));
|
|
string data = table.serialize_binary_v4();
|
|
if (!args.get<bool>("decompressed")) {
|
|
data = prs_compress_optimal(data);
|
|
}
|
|
write_output_data(args, data.data(), data.size(), nullptr);
|
|
});
|
|
|
|
Action a_decode_battle_params(
|
|
"decode-battle-params", nullptr,
|
|
+[](phosg::Arguments& args) {
|
|
auto data_on_ep1 = std::make_shared<std::string>(phosg::load_file(args.get<std::string>(1)));
|
|
auto data_on_ep2 = std::make_shared<std::string>(phosg::load_file(args.get<std::string>(2)));
|
|
auto data_on_ep4 = std::make_shared<std::string>(phosg::load_file(args.get<std::string>(3)));
|
|
auto data_off_ep1 = std::make_shared<std::string>(phosg::load_file(args.get<std::string>(4)));
|
|
auto data_off_ep2 = std::make_shared<std::string>(phosg::load_file(args.get<std::string>(5)));
|
|
auto data_off_ep4 = std::make_shared<std::string>(phosg::load_file(args.get<std::string>(6)));
|
|
BinaryBattleParamsIndex index(data_on_ep1, data_on_ep2, data_on_ep4, data_off_ep1, data_off_ep2, data_off_ep4);
|
|
auto json = index.json();
|
|
uint32_t serialize_options = phosg::JSON::SerializeOption::FORMAT | phosg::JSON::SerializeOption::SORT_DICT_KEYS;
|
|
if (args.get<bool>("hex")) {
|
|
serialize_options |= phosg::JSON::SerializeOption::HEX_INTEGERS;
|
|
}
|
|
phosg::save_file(args.get<std::string>(7), json.serialize(serialize_options));
|
|
});
|
|
|
|
Action a_encode_battle_params(
|
|
"encode-battle-params", nullptr,
|
|
+[](phosg::Arguments& args) {
|
|
JSONBattleParamsIndex index(phosg::JSON::parse(read_input_data(args)));
|
|
std::string pfx = args.get<string>(2);
|
|
phosg::save_file(pfx + "_on.dat", &index.get_table(false, Episode::EP1), sizeof(BattleParamsIndex::Table));
|
|
phosg::save_file(pfx + "_lab_on.dat", &index.get_table(false, Episode::EP2), sizeof(BattleParamsIndex::Table));
|
|
phosg::save_file(pfx + "_ep4_on.dat", &index.get_table(false, Episode::EP4), sizeof(BattleParamsIndex::Table));
|
|
phosg::save_file(pfx + ".dat", &index.get_table(true, Episode::EP1), sizeof(BattleParamsIndex::Table));
|
|
phosg::save_file(pfx + "_lab.dat", &index.get_table(true, Episode::EP2), sizeof(BattleParamsIndex::Table));
|
|
phosg::save_file(pfx + "_ep4.dat", &index.get_table(true, Episode::EP4), sizeof(BattleParamsIndex::Table));
|
|
});
|
|
|
|
Action a_find_rel_section(
|
|
"find-rel-sections", nullptr,
|
|
+[](phosg::Arguments& args) {
|
|
auto data = read_input_data(args);
|
|
auto offsets = args.get<bool>("big-endian")
|
|
? all_relocation_offsets_for_rel_file<true>(data.data(), data.size())
|
|
: all_relocation_offsets_for_rel_file<false>(data.data(), data.size());
|
|
for (uint32_t offset : offsets) {
|
|
phosg::fwrite_fmt(stdout, "{:08X}\n", offset);
|
|
}
|
|
});
|
|
|
|
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",
|
|
+[](phosg::Arguments& args) {
|
|
string description = args.get<string>(1);
|
|
auto version = get_cli_version(args);
|
|
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->load_config_early();
|
|
s->load_patch_indexes();
|
|
s->load_text_index();
|
|
s->load_item_definitions();
|
|
s->load_item_name_indexes();
|
|
auto name_index = s->item_name_index(version);
|
|
|
|
ItemData item = name_index->parse_item_description(description);
|
|
|
|
if (args.get<bool>("decode")) {
|
|
item.decode_for_version(version);
|
|
}
|
|
|
|
string desc = name_index->describe_item(item);
|
|
string desc_colored = name_index->describe_item(item, ItemNameIndex::Flag::INCLUDE_PSO_COLOR_ESCAPES);
|
|
|
|
phosg::log_info_f("Data (decoded): {:02X}{:02X}{:02X}{:02X} {:02X}{:02X}{:02X}{:02X} {:02X}{:02X}{:02X}{:02X} -------- {:02X}{:02X}{:02X}{:02X}",
|
|
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);
|
|
|
|
phosg::log_info_f("Data (V2-encoded): {:02X}{:02X}{:02X}{:02X} {:02X}{:02X}{:02X}{:02X} {:02X}{:02X}{:02X}{:02X} -------- {:02X}{:02X}{:02X}{:02X}",
|
|
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) {
|
|
phosg::log_warning_f("V2-decoded data does not match original data");
|
|
phosg::log_warning_f("Data (V2-decoded): {:02X}{:02X}{:02X}{:02X} {:02X}{:02X}{:02X}{:02X} {:02X}{:02X}{:02X}{:02X} -------- {:02X}{:02X}{:02X}{:02X}",
|
|
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);
|
|
|
|
phosg::log_info_f("Data (GC-encoded): {:02X}{:02X}{:02X}{:02X} {:02X}{:02X}{:02X}{:02X} {:02X}{:02X}{:02X}{:02X} -------- {:02X}{:02X}{:02X}{:02X}",
|
|
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) {
|
|
phosg::log_warning_f("GC-decoded data does not match original data");
|
|
phosg::log_warning_f("Data (GC-decoded): {:02X}{:02X}{:02X}{:02X} {:02X}{:02X}{:02X}{:02X} {:02X}{:02X}{:02X}{:02X} -------- {:02X}{:02X}{:02X}{:02X}",
|
|
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]);
|
|
}
|
|
|
|
phosg::log_info_f("Description: {}", desc);
|
|
phosg::log_info_f("Description (in-game): {}", desc_colored);
|
|
|
|
size_t purchase_price = s->item_parameter_table(Version::BB_V4)->price_for_item(item);
|
|
size_t sale_price = purchase_price >> 3;
|
|
phosg::log_info_f("Purchase price: {}; sale price: {}", purchase_price, sale_price);
|
|
});
|
|
|
|
Action a_name_all_items(
|
|
"name-all-items", nullptr, +[](phosg::Arguments& args) {
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->clear_file_caches();
|
|
s->load_config_early();
|
|
s->load_patch_indexes();
|
|
s->load_text_index();
|
|
s->load_item_definitions();
|
|
s->load_item_name_indexes();
|
|
s->load_ep3_cards();
|
|
s->load_config_late();
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (args.get<bool>("list")) {
|
|
for (uint32_t primary_identifier : all_primary_identifiers) {
|
|
phosg::fwrite_fmt(stdout, "{:08X}\n", primary_identifier);
|
|
for (Version v : ALL_VERSIONS) {
|
|
const auto& index = s->item_name_index_opt(v);
|
|
if (index) {
|
|
auto pmt = s->item_parameter_table(v);
|
|
ItemData item = ItemData::from_primary_identifier(*s->item_stack_limits(v), primary_identifier);
|
|
string name = index->describe_item(item);
|
|
try {
|
|
bool is_rare = pmt->is_item_rare(item);
|
|
phosg::fwrite_fmt(stdout, " {:10}: {} {}\n", phosg::name_for_enum(v), is_rare ? "+++" : "---", name);
|
|
} catch (const out_of_range&) {
|
|
phosg::fwrite_fmt(stdout, " {:10}: (missing)\n", phosg::name_for_enum(v));
|
|
}
|
|
}
|
|
}
|
|
fputc('\n', stdout);
|
|
}
|
|
} else {
|
|
bool separate_classes = args.get<bool>("separate-classes");
|
|
|
|
auto print_header = [&]() -> void {
|
|
phosg::fwrite_fmt(stdout, "IDENT :");
|
|
for (Version v : ALL_VERSIONS) {
|
|
const auto& index = s->item_name_index_opt(v);
|
|
if (index) {
|
|
phosg::fwrite_fmt(stdout, " {:30} ", phosg::name_for_enum(v));
|
|
}
|
|
}
|
|
fputc('\n', stdout);
|
|
};
|
|
|
|
print_header();
|
|
uint32_t prev_ident = 0;
|
|
for (uint32_t primary_identifier : all_primary_identifiers) {
|
|
if (separate_classes & ((primary_identifier & 0xFFFF0000) != (prev_ident & 0xFFFF0000))) {
|
|
fputc('\n', stdout);
|
|
print_header();
|
|
}
|
|
prev_ident = primary_identifier;
|
|
|
|
phosg::fwrite_fmt(stdout, "{:08X}:", primary_identifier);
|
|
for (Version v : ALL_VERSIONS) {
|
|
const auto& index = s->item_name_index_opt(v);
|
|
if (index) {
|
|
auto pmt = s->item_parameter_table(v);
|
|
ItemData item = ItemData::from_primary_identifier(*s->item_stack_limits(v), primary_identifier);
|
|
if (index->exists(item)) {
|
|
string name = index->describe_item(item);
|
|
bool is_rare = pmt->is_item_rare(item);
|
|
phosg::fwrite_fmt(stdout, " {:30}{}", name, is_rare ? " ***" : " ...");
|
|
} else {
|
|
phosg::fwrite_fmt(stdout, " ------------------------------ ---");
|
|
}
|
|
}
|
|
}
|
|
fputc('\n', stdout);
|
|
}
|
|
}
|
|
});
|
|
|
|
Action a_print_level_stats(
|
|
"show-level-tables", "\
|
|
show-level-tables\n\
|
|
Print the level tables for each version in a semi-human-reatable format.\n",
|
|
+[](phosg::Arguments& args) {
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->load_config_early();
|
|
s->clear_file_caches();
|
|
s->load_patch_indexes();
|
|
s->load_level_tables();
|
|
|
|
vector<PlayerStats> level_1_v1_v2;
|
|
vector<PlayerStats> level_100_v1_v2;
|
|
vector<PlayerStats> level_200_v1_v2;
|
|
vector<PlayerStats> level_200_limit_v1_v2;
|
|
vector<PlayerStats> level_1_v3;
|
|
vector<PlayerStats> level_200_v3;
|
|
vector<PlayerStats> level_200_limit_v3;
|
|
vector<PlayerStats> level_1_v4;
|
|
vector<PlayerStats> level_200_v4;
|
|
vector<PlayerStats> level_200_limit_v4;
|
|
for (size_t z = 0; z < 12; z++) {
|
|
if (z < 9) {
|
|
level_1_v1_v2.emplace_back().char_stats = s->level_table_v1_v2->base_stats_for_class(z);
|
|
level_200_limit_v1_v2.emplace_back(s->level_table_v1_v2->max_stats_for_class(z));
|
|
s->level_table_v1_v2->advance_to_level(level_100_v1_v2.emplace_back(level_1_v1_v2.back()), 99, z);
|
|
s->level_table_v1_v2->advance_to_level(level_200_v1_v2.emplace_back(level_1_v1_v2.back()), 199, z);
|
|
}
|
|
|
|
level_1_v3.emplace_back().char_stats = s->level_table_v3->base_stats_for_class(z);
|
|
s->level_table_v3->advance_to_level(level_200_v3.emplace_back(level_1_v3.back()), 199, z);
|
|
level_200_limit_v3.emplace_back(s->level_table_v3->max_stats_for_class(z));
|
|
|
|
level_1_v4.emplace_back().char_stats = s->level_table_v4->base_stats_for_class(z);
|
|
s->level_table_v4->advance_to_level(level_200_v4.emplace_back(level_1_v3.back()), 199, z);
|
|
level_200_limit_v4.emplace_back(s->level_table_v4->max_stats_for_class(z));
|
|
}
|
|
|
|
auto print_stats_set = [](const vector<PlayerStats>& stats_vec, const char* name) -> void {
|
|
phosg::fwrite_fmt(stdout, "{} ", name);
|
|
for (size_t z = 0; z < stats_vec.size(); z++) {
|
|
phosg::fwrite_fmt(stdout, " {}", abbreviation_for_char_class(z));
|
|
}
|
|
|
|
phosg::fwrite_fmt(stdout, "\n{} ATP", name);
|
|
for (const auto& stats : stats_vec) {
|
|
phosg::fwrite_fmt(stdout, " {:4}", stats.char_stats.atp);
|
|
}
|
|
phosg::fwrite_fmt(stdout, "\n{} DFP", name);
|
|
for (const auto& stats : stats_vec) {
|
|
phosg::fwrite_fmt(stdout, " {:4}", stats.char_stats.dfp);
|
|
}
|
|
phosg::fwrite_fmt(stdout, "\n{} MST", name);
|
|
for (const auto& stats : stats_vec) {
|
|
phosg::fwrite_fmt(stdout, " {:4}", stats.char_stats.mst);
|
|
}
|
|
phosg::fwrite_fmt(stdout, "\n{} ATA", name);
|
|
for (const auto& stats : stats_vec) {
|
|
phosg::fwrite_fmt(stdout, " {:4}", stats.char_stats.ata);
|
|
}
|
|
phosg::fwrite_fmt(stdout, "\n{} EVP", name);
|
|
for (const auto& stats : stats_vec) {
|
|
phosg::fwrite_fmt(stdout, " {:4}", stats.char_stats.evp);
|
|
}
|
|
phosg::fwrite_fmt(stdout, "\n{} LCK", name);
|
|
for (const auto& stats : stats_vec) {
|
|
phosg::fwrite_fmt(stdout, " {:4}", stats.char_stats.lck);
|
|
}
|
|
phosg::fwrite_fmt(stdout, "\n{} HP", name);
|
|
for (const auto& stats : stats_vec) {
|
|
phosg::fwrite_fmt(stdout, " {:4}", stats.char_stats.hp);
|
|
}
|
|
fputc('\n', stdout);
|
|
};
|
|
|
|
print_stats_set(level_1_v1_v2, "v1/v2 Lv.1 ");
|
|
print_stats_set(level_100_v1_v2, "v1/v2 Lv.100");
|
|
print_stats_set(level_200_v1_v2, "v2 Lv.200 ");
|
|
print_stats_set(level_200_limit_v1_v2, "v2 limit ");
|
|
print_stats_set(level_1_v3, "v3 Lv.1 ");
|
|
print_stats_set(level_200_v3, "v3 Lv.200 ");
|
|
print_stats_set(level_200_limit_v3, "v3 limit ");
|
|
print_stats_set(level_1_v4, "v4 Lv.1 ");
|
|
print_stats_set(level_200_v4, "v4 Lv.200 ");
|
|
print_stats_set(level_200_limit_v4, "v4 limit ");
|
|
});
|
|
|
|
Action a_show_item_parameter_tables(
|
|
"show-item-parameter-tables", "\
|
|
show-item-parameter-tables\n\
|
|
Print the item parameter tables for each version in a semi-human-reatable\n\
|
|
format.\n",
|
|
+[](phosg::Arguments& args) {
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->load_all(false);
|
|
for (Version v : ALL_VERSIONS) {
|
|
const auto& index = s->item_name_index_opt(v);
|
|
if (index) {
|
|
phosg::fwrite_fmt(stdout, "======== {}\n", phosg::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",
|
|
+[](phosg::Arguments& args) {
|
|
bool one_line = args.get<bool>("one-line");
|
|
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->load_ep3_cards();
|
|
|
|
unique_ptr<BinaryTextSet> text_english;
|
|
try {
|
|
phosg::JSON json = phosg::JSON::parse(phosg::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();
|
|
phosg::log_info_f("{} 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) {
|
|
phosg::fwrite_fmt(stdout, "{}\n", def_str);
|
|
} else {
|
|
phosg::fwrite_fmt(stdout, "{}\n", def_str);
|
|
if (!entry->debug_tags.empty()) {
|
|
string tags = phosg::join(entry->debug_tags, ", ");
|
|
phosg::fwrite_fmt(stdout, " Tags: {}\n", tags);
|
|
}
|
|
if (!entry->dice_caption.empty()) {
|
|
phosg::fwrite_fmt(stdout, " Dice caption: {}\n", entry->dice_caption);
|
|
}
|
|
if (!entry->dice_caption.empty()) {
|
|
phosg::fwrite_fmt(stdout, " Dice text: {}\n", entry->dice_text);
|
|
}
|
|
if (!entry->text.empty()) {
|
|
string text = phosg::str_replace_all(entry->text, "\n", "\n ");
|
|
phosg::strip_trailing_whitespace(text);
|
|
phosg::fwrite_fmt(stdout, " Text:\n {}\n", text);
|
|
}
|
|
fputc('\n', stdout);
|
|
}
|
|
}
|
|
});
|
|
|
|
Action a_generate_ep3_cards_html(
|
|
"generate-ep3-cards-html", "\
|
|
generate-ep3-cards-html [--ep3-nte] [--compare] [--threads=N] [--no-images]\n\
|
|
[--no-disassembly]\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. If --no-images is given, omit the card images.\n",
|
|
+[](phosg::Arguments& args) {
|
|
size_t num_threads = args.get<size_t>("threads", 0);
|
|
|
|
bool include_nte = (get_cli_version(args, Version::GC_EP3) == Version::GC_EP3_NTE) || args.get<bool>("compare");
|
|
bool include_final = (get_cli_version(args, Version::GC_EP3) == Version::GC_EP3) || args.get<bool>("compare");
|
|
bool no_images = args.get<bool>("no-images");
|
|
bool no_large_images = args.get<bool>("no-large-images");
|
|
bool no_disassembly = args.get<bool>("no-disassembly");
|
|
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->clear_file_caches();
|
|
s->load_patch_indexes();
|
|
s->load_text_index();
|
|
s->load_ep3_cards();
|
|
|
|
shared_ptr<const TextSet> text_english;
|
|
try {
|
|
text_english = s->text_index->get(Version::GC_EP3, Language::ENGLISH);
|
|
} catch (const out_of_range&) {
|
|
}
|
|
|
|
struct VersionInfo {
|
|
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();
|
|
}
|
|
};
|
|
|
|
const char* name;
|
|
vector<CardInfo> card_infos;
|
|
bool show_large_column = false;
|
|
bool show_medium_column = false;
|
|
bool show_small_column = false;
|
|
size_t num_output_columns = 2;
|
|
|
|
VersionInfo(
|
|
const char* name,
|
|
shared_ptr<const Episode3::CardIndex> card_index,
|
|
const char* cardtex_directory,
|
|
bool no_large_images,
|
|
size_t num_threads,
|
|
bool no_disassembly)
|
|
: name(name) {
|
|
for (uint32_t card_id : card_index->all_ids()) {
|
|
if (this->card_infos.size() <= card_id) {
|
|
this->card_infos.resize(card_id + 1);
|
|
}
|
|
this->card_infos[card_id].ce = card_index->definition_for_id(card_id);
|
|
}
|
|
|
|
if (cardtex_directory) {
|
|
for (const auto& item : std::filesystem::directory_iterator(cardtex_directory)) {
|
|
string filename = item.path().filename().string();
|
|
if ((filename[0] == 'C' || filename[0] == 'M' || filename[0] == 'L') && (filename[1] == '_')) {
|
|
size_t card_id = stoull(filename.substr(2, 3), nullptr, 10);
|
|
if (this->card_infos.size() <= card_id) {
|
|
this->card_infos.resize(card_id + 1);
|
|
}
|
|
auto& info = this->card_infos[card_id];
|
|
if (filename[0] == 'C' && !no_large_images) {
|
|
info.large_filename = string(cardtex_directory) + "/" + filename;
|
|
this->show_large_column = true;
|
|
} else if (filename[0] == 'L') {
|
|
info.medium_filename = string(cardtex_directory) + "/" + filename;
|
|
this->show_medium_column = true;
|
|
} else if (filename[0] == 'M') {
|
|
info.small_filename = string(cardtex_directory) + "/" + filename;
|
|
this->show_small_column = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
phosg::parallel_range(
|
|
this->card_infos, [&](CardInfo& info, size_t) -> bool {
|
|
if (!info.large_filename.empty()) {
|
|
auto img = phosg::ImageRGBA8888N::from_file_data(phosg::load_file(info.large_filename));
|
|
img.resize(512, 399);
|
|
info.large_data_url = img.serialize(phosg::ImageFormat::PNG_DATA_URL);
|
|
}
|
|
if (!info.medium_filename.empty()) {
|
|
auto img = phosg::ImageRGBA8888N::from_file_data(phosg::load_file(info.medium_filename));
|
|
img.resize(184, 144);
|
|
info.medium_data_url = img.serialize(phosg::ImageFormat::PNG_DATA_URL);
|
|
}
|
|
if (!info.small_filename.empty()) {
|
|
auto img = phosg::ImageRGBA8888N::from_file_data(phosg::load_file(info.small_filename));
|
|
img.resize(58, 43);
|
|
info.small_data_url = img.serialize(phosg::ImageFormat::PNG_DATA_URL);
|
|
}
|
|
return false;
|
|
},
|
|
num_threads);
|
|
}
|
|
|
|
this->num_output_columns = 1 + (!no_disassembly) + this->show_small_column + this->show_medium_column + this->show_large_column;
|
|
}
|
|
|
|
const CardInfo* get_entry(size_t card_id) const {
|
|
if (card_id >= this->card_infos.size()) {
|
|
return nullptr;
|
|
}
|
|
const auto* entry = &this->card_infos[card_id];
|
|
return entry->is_empty() ? nullptr : entry;
|
|
}
|
|
};
|
|
|
|
vector<VersionInfo> version_infos;
|
|
if (include_nte) {
|
|
version_infos.emplace_back("NTE", s->ep3_card_index_trial, no_images ? nullptr : "system/ep3/cardtex-trial", no_large_images, num_threads, no_disassembly);
|
|
}
|
|
if (include_final) {
|
|
version_infos.emplace_back("Final", s->ep3_card_index, no_images ? nullptr : "system/ep3/cardtex", no_large_images, num_threads, no_disassembly);
|
|
}
|
|
|
|
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 />");
|
|
|
|
if (version_infos.size() > 1) {
|
|
blocks.emplace_back("<table><tr><th rowspan=\"2\" style=\"text-align: left; padding: 4px\">ID</th>");
|
|
for (const auto& vi : version_infos) {
|
|
blocks.emplace_back(std::format("<th colspan=\"{}\" style=\"text-align: left; padding: 4px\">{}</th>",
|
|
vi.num_output_columns, vi.name));
|
|
}
|
|
blocks.emplace_back("</tr><tr>");
|
|
} else {
|
|
blocks.emplace_back("<table><tr><th style=\"text-align: left; padding: 4px\">ID</th>");
|
|
}
|
|
for (const auto& vi : version_infos) {
|
|
if (vi.show_small_column) {
|
|
blocks.emplace_back("<th style=\"text-align: left; padding: 4px\">Small</th>");
|
|
}
|
|
if (vi.show_medium_column) {
|
|
blocks.emplace_back("<th style=\"text-align: left; padding: 4px\">Medium</th>");
|
|
}
|
|
if (vi.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>");
|
|
}
|
|
blocks.emplace_back("</tr>");
|
|
|
|
size_t num_infos = 0;
|
|
for (const auto& vi : version_infos) {
|
|
num_infos = std::max<size_t>(num_infos, vi.card_infos.size());
|
|
}
|
|
|
|
for (size_t card_id = 0; card_id < num_infos; card_id++) {
|
|
bool any_vi_has_entry = false;
|
|
for (const auto& vi : version_infos) {
|
|
if (vi.get_entry(card_id)) {
|
|
any_vi_has_entry = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!any_vi_has_entry) {
|
|
continue;
|
|
}
|
|
|
|
blocks.emplace_back(std::format("<tr><td style=\"padding: 4px; vertical-align: top\"><pre>{:04X}</pre></td>", card_id));
|
|
|
|
for (const auto& vi : version_infos) {
|
|
const VersionInfo::CardInfo* entry = vi.get_entry(card_id);
|
|
if (!entry) {
|
|
blocks.emplace_back(std::format("<td colspan=\"{}\" style=\"padding: 4px; vertical-align: top\"><pre>No entry</pre></td>",
|
|
vi.num_output_columns));
|
|
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";
|
|
}
|
|
|
|
string td_tag = std::format("<td style=\"padding: 4px; vertical-align: top; background-color: {}\">", background_color);
|
|
if (vi.show_small_column) {
|
|
blocks.emplace_back(td_tag);
|
|
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 (vi.show_medium_column) {
|
|
blocks.emplace_back(td_tag);
|
|
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 (vi.show_large_column) {
|
|
blocks.emplace_back(td_tag);
|
|
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_tag);
|
|
if (entry->ce) {
|
|
blocks.emplace_back("<pre>");
|
|
blocks.emplace_back(entry->ce->text);
|
|
blocks.emplace_back("</pre></td>");
|
|
if (!no_disassembly) {
|
|
blocks.emplace_back(td_tag);
|
|
blocks.emplace_back("<pre>");
|
|
blocks.emplace_back(entry->ce->def.str(false, text_english.get()));
|
|
blocks.emplace_back("</pre></td>");
|
|
}
|
|
} else {
|
|
blocks.emplace_back("</td>");
|
|
if (!no_disassembly) {
|
|
blocks.emplace_back(td_tag);
|
|
blocks.emplace_back("<pre>Definition is missing</pre>");
|
|
blocks.emplace_back("</td>");
|
|
}
|
|
}
|
|
}
|
|
blocks.emplace_back("</tr>");
|
|
}
|
|
blocks.emplace_back("</table></body></html>");
|
|
|
|
phosg::save_file("cards.html", phosg::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",
|
|
+[](phosg::Arguments& args) {
|
|
config_log.info_f("Collecting Episode 3 data");
|
|
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->load_ep3_cards();
|
|
s->load_ep3_maps();
|
|
|
|
const auto& all_maps = s->ep3_map_index->all_maps();
|
|
phosg::log_info_f("{} maps", all_maps.size());
|
|
for (const auto& [map_number, map] : all_maps) {
|
|
const auto& vms = map->all_versions();
|
|
for (size_t lang_index = 0; lang_index < vms.size(); lang_index++) {
|
|
if (!vms[lang_index]) {
|
|
continue;
|
|
}
|
|
Language language = static_cast<Language>(lang_index);
|
|
string map_s = vms[lang_index]->map->str(s->ep3_card_index.get(), language);
|
|
phosg::fwrite_fmt(stdout, "({}) {}\n", char_for_language(language), map_s);
|
|
}
|
|
}
|
|
});
|
|
|
|
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",
|
|
+[](phosg::Arguments& args) {
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->load_patch_indexes();
|
|
s->load_battle_params();
|
|
|
|
phosg::fwrite_fmt(stdout, "Episode 1 multi\n");
|
|
s->battle_params->get_table(false, Episode::EP1).print(stdout, Episode::EP1);
|
|
phosg::fwrite_fmt(stdout, "Episode 1 solo\n");
|
|
s->battle_params->get_table(true, Episode::EP1).print(stdout, Episode::EP1);
|
|
phosg::fwrite_fmt(stdout, "Episode 2 multi\n");
|
|
s->battle_params->get_table(false, Episode::EP2).print(stdout, Episode::EP2);
|
|
phosg::fwrite_fmt(stdout, "Episode 2 solo\n");
|
|
s->battle_params->get_table(true, Episode::EP2).print(stdout, Episode::EP2);
|
|
phosg::fwrite_fmt(stdout, "Episode 4 multi\n");
|
|
s->battle_params->get_table(false, Episode::EP4).print(stdout, Episode::EP4);
|
|
phosg::fwrite_fmt(stdout, "Episode 4 solo\n");
|
|
s->battle_params->get_table(true, Episode::EP4).print(stdout, Episode::EP4);
|
|
});
|
|
|
|
Action a_check_supermaps(
|
|
"check-supermaps", "\
|
|
check-supermaps [--disassemble] [--generate-enemy-stats]\n\
|
|
Checks the supermaps for all free-play areas and quests. If --disassemble\n\
|
|
is given, saves the contents of each supermap to text files in the current\n\
|
|
directory. If --generate-enemy-stats is given, saves tables of enemy\n\
|
|
counts for each quest to text files in the current directory.\n",
|
|
+[](phosg::Arguments& args) {
|
|
bool save_disassembly = args.get<bool>("disassemble");
|
|
bool generate_enemy_stats = args.get<bool>("generate-enemy-stats");
|
|
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->load_config_early();
|
|
s->clear_file_caches();
|
|
s->load_patch_indexes();
|
|
s->load_set_data_tables();
|
|
s->load_maps();
|
|
|
|
auto rand_crypt = make_shared<MT19937Generator>(phosg::random_object<uint32_t>());
|
|
|
|
// Generate MapStates for a few random variations
|
|
for (size_t z = 0; z < 0x20; z++) {
|
|
Episode episode = ALL_EPISODES_V4[phosg::random_object<uint32_t>() % ALL_EPISODES_V4.size()];
|
|
GameMode mode = ALL_GAME_MODES_V4[phosg::random_object<uint32_t>() % ALL_GAME_MODES_V4.size()];
|
|
Difficulty difficulty = static_cast<Difficulty>(phosg::random_object<uint32_t>() % 4);
|
|
uint8_t event = phosg::random_object<uint32_t>() % 8;
|
|
uint32_t random_seed = phosg::random_object<uint32_t>();
|
|
phosg::fwrite_fmt(stderr, "FREE MAP STATE TEST: {} {} {}\n",
|
|
abbreviation_for_episode(episode),
|
|
abbreviation_for_mode(mode),
|
|
abbreviation_for_difficulty(difficulty));
|
|
|
|
auto sdt = s->set_data_table(Version::BB_V4, episode, mode, difficulty);
|
|
auto variations = sdt->generate_variations(episode, (mode == GameMode::SOLO), rand_crypt);
|
|
auto supermaps = s->supermaps_for_variations(episode, mode, difficulty, variations);
|
|
auto map_state = make_shared<MapState>(
|
|
0, difficulty, event, random_seed, MapState::DEFAULT_RARE_ENEMIES, rand_crypt, supermaps);
|
|
map_state->verify();
|
|
|
|
phosg::fwrite_fmt(stderr, " map state ok: 0x{:X} objects, 0x{:X} enemies, 0x{:X} enemy sets, 0x{:X} events\n",
|
|
map_state->object_states.size(),
|
|
map_state->enemy_states.size(),
|
|
map_state->enemy_set_states.size(),
|
|
map_state->event_states.size());
|
|
}
|
|
|
|
SuperMap::EfficiencyStats all_free_maps_eff;
|
|
for (const auto& [key, supermap] : s->supermap_for_free_play_key) {
|
|
auto episode = static_cast<Episode>((key >> 28) & 7);
|
|
auto mode = static_cast<GameMode>((key >> 26) & 3);
|
|
Difficulty difficulty = static_cast<Difficulty>((key >> 24) & 3);
|
|
uint8_t floor = (key >> 16) & 0xFF;
|
|
uint8_t layout = (key >> 8) & 0xFF;
|
|
uint8_t entities = (key >> 0) & 0xFF;
|
|
|
|
if (supermap) {
|
|
string filename_token;
|
|
if (save_disassembly) {
|
|
string filename = std::format(
|
|
"supermap_{}_{}_{}_{:02X}_{:02X}_{:02X}.txt",
|
|
abbreviation_for_episode(episode),
|
|
abbreviation_for_mode(mode),
|
|
abbreviation_for_difficulty(difficulty),
|
|
floor, layout, entities);
|
|
auto f = phosg::fopen_unique(filename, "wt");
|
|
supermap->print(f.get());
|
|
filename_token = " => " + filename;
|
|
}
|
|
|
|
auto eff = supermap->efficiency();
|
|
all_free_maps_eff += eff;
|
|
auto eff_str = eff.str();
|
|
phosg::fwrite_fmt(stderr, "FREE MAP: {:08X} => {} {} {} floor={:02X} layout={:02X} entities={:02X} => {}{}\n",
|
|
key,
|
|
abbreviation_for_episode(episode),
|
|
abbreviation_for_mode(mode),
|
|
abbreviation_for_difficulty(difficulty),
|
|
floor, layout, entities, eff_str, filename_token);
|
|
|
|
} else {
|
|
phosg::fwrite_fmt(stderr, "FREE MAP: {:08X} => {} {} {} floor={:02X} layout={:02X} entities={:02X} => NO MAP\n",
|
|
key,
|
|
abbreviation_for_episode(episode),
|
|
abbreviation_for_mode(mode),
|
|
abbreviation_for_difficulty(difficulty),
|
|
floor, layout, entities);
|
|
}
|
|
}
|
|
|
|
string all_free_maps_eff_str = all_free_maps_eff.str();
|
|
phosg::fwrite_fmt(stderr, "ALL FREE MAPS: {}\n", all_free_maps_eff_str);
|
|
|
|
s->load_quest_index();
|
|
|
|
SuperMap::EfficiencyStats all_quests_eff;
|
|
uint32_t random_seed = args.get<uint32_t>("random-seed", 0, phosg::Arguments::IntFormat::HEX);
|
|
for (const auto& it : s->quest_index->quests_by_number) {
|
|
auto supermap = it.second->get_supermap(random_seed);
|
|
if (!supermap) {
|
|
throw logic_error("quest does not have a supermap, even with a specified random seed");
|
|
}
|
|
|
|
string filename_token;
|
|
if (save_disassembly) {
|
|
string filename = std::format("supermap_quest_{}_{:08X}.txt", it.first, random_seed);
|
|
auto f = phosg::fopen_unique(filename, "wt");
|
|
phosg::fwrite_fmt(f.get(), "QUEST {} ({})\n", it.first, it.second->meta.name);
|
|
supermap->print(f.get());
|
|
filename_token = " => " + filename;
|
|
}
|
|
if (generate_enemy_stats) {
|
|
array<unordered_map<EnemyType, size_t>, NUM_VERSIONS> counts_for_version;
|
|
for (Version v : ALL_NON_PATCH_VERSIONS) {
|
|
counts_for_version[static_cast<size_t>(v)] = supermap->count_enemy_sets_for_version(v);
|
|
}
|
|
string filename = std::format("supermap_quest_{}_{:08X}_enemy_counts.txt", it.first, random_seed);
|
|
auto f = phosg::fopen_unique(filename, "wt");
|
|
phosg::fwrite_fmt(f.get(), "QUEST {} ({})\n", it.first, it.second->meta.name);
|
|
phosg::fwrite_fmt(f.get(), "ENEMY--------------- DCNTE 11/2K DC-V1 DC-V2 PCNTE PC-V2 GCNTE GC-V3 XB-V3 BB-V4\n");
|
|
for (auto type : phosg::EnumRange<EnemyType>()) {
|
|
bool any_count_nonzero = false;
|
|
array<size_t, NUM_VERSIONS> counts;
|
|
for (Version v : ALL_NON_PATCH_VERSIONS) {
|
|
size_t& count = counts[static_cast<size_t>(v)];
|
|
try {
|
|
count = counts_for_version[static_cast<size_t>(v)].at(type);
|
|
} catch (const out_of_range&) {
|
|
count = 0;
|
|
}
|
|
if (count != 0) {
|
|
any_count_nonzero = true;
|
|
}
|
|
}
|
|
if (any_count_nonzero) {
|
|
phosg::fwrite_fmt(f.get(), "{:20}", phosg::name_for_enum(type));
|
|
for (Version v : ALL_NON_PATCH_VERSIONS) {
|
|
size_t count = counts[static_cast<size_t>(v)];
|
|
if (count > 0) {
|
|
phosg::fwrite_fmt(f.get(), " {:5}", count);
|
|
} else {
|
|
fputs(" ", f.get());
|
|
}
|
|
}
|
|
fputc('\n', f.get());
|
|
}
|
|
}
|
|
}
|
|
auto eff = supermap->efficiency();
|
|
all_quests_eff += eff;
|
|
auto eff_str = eff.str();
|
|
phosg::fwrite_fmt(stderr, "QUEST MAP: {:08X} => {}{}\n", it.first, eff_str, filename_token);
|
|
|
|
auto map_state = make_shared<MapState>(
|
|
0,
|
|
static_cast<Difficulty>(phosg::random_object<uint8_t>() & 3),
|
|
0,
|
|
phosg::random_object<uint32_t>(),
|
|
MapState::DEFAULT_RARE_ENEMIES,
|
|
rand_crypt,
|
|
supermap);
|
|
map_state->verify();
|
|
|
|
phosg::fwrite_fmt(stderr, " map state ok: 0x{:X} objects, 0x{:X} enemies, 0x{:X} enemy sets, 0x{:X} events\n",
|
|
map_state->object_states.size(),
|
|
map_state->enemy_states.size(),
|
|
map_state->enemy_set_states.size(),
|
|
map_state->event_states.size());
|
|
}
|
|
string all_quests_eff_str = all_quests_eff.str();
|
|
phosg::fwrite_fmt(stderr, "ALL QUEST MAPS: {}\n", all_quests_eff_str);
|
|
});
|
|
|
|
Action a_materialize_map(
|
|
"materialize-map", "\
|
|
materialize-map [OPTIONS] [INPUT-FILENAME [OUTPUT-FILENAME]]\n\
|
|
Runs the Challenge Mode random enemy generation algorithm on the input map\n\
|
|
file, producing a new map file with no random sections. A version option\n\
|
|
is required, and the --seed=SEED option is also required (SEED is a 32-bit\n\
|
|
hex integer). If --disassemble is given, disassembles the result instead\n\
|
|
of generating the map data.\n",
|
|
+[](phosg::Arguments& args) {
|
|
if (args.get<bool>("debug")) {
|
|
static_game_data_log.min_level = phosg::LogLevel::L_DEBUG;
|
|
}
|
|
auto map_data = make_shared<string>(prs_decompress(read_input_data(args)));
|
|
auto map_file = make_shared<MapFile>(map_data);
|
|
if (!map_file->has_random_sections()) {
|
|
throw std::runtime_error("input map file does not have any random sections");
|
|
}
|
|
|
|
uint32_t seed = args.get<uint32_t>("seed", phosg::Arguments::IntFormat::HEX);
|
|
auto materialized = map_file->materialize_random_sections(seed);
|
|
if (args.get<bool>("disassemble")) {
|
|
Version version = get_cli_version(args);
|
|
auto disassembly = materialized->disassemble(false, version);
|
|
write_output_data(args, disassembly.data(), disassembly.size(), "txt");
|
|
} else {
|
|
auto new_data = prs_compress_optimal(materialized->serialize());
|
|
write_output_data(args, new_data.data(), new_data.size(), "dat");
|
|
}
|
|
});
|
|
Action a_optimize_materialized_map(
|
|
"optimize-materialized-map", "\
|
|
optimize-materialized-map [OPTIONS] [INPUT-FILENAME]\n\
|
|
Runs the Challenge Mode random enemy generation algorithm on the input map\n\
|
|
file, looking for the seed that results in the fewest extra events and the\n\
|
|
fewest enemies overall, optionally restricting to specific enemy types. A\n\
|
|
version option is required. Other options:\n\
|
|
--minimize=TYPE[:PARAM:VALUE]: Try to find seeds that result in the\n\
|
|
fewest instances of this enemy (may be given multiple times). TYPE\n\
|
|
should be an integer (for example, 0x0040 for Hildebears and\n\
|
|
Hildeblues). This can also filter by a param value (for example,\n\
|
|
0x0044:6:2 for Gigoboomas). See Map.cc for a full listing of types\n\
|
|
and parameters). Event count always takes precedence; that is, a map\n\
|
|
with fewer events is always considered better than any map with more\n\
|
|
events, regardless of the enemy counts.\n\
|
|
--restrict-room=FLOOR:ROOM-ID: Ignore all enemies outside of this room\n\
|
|
(may be given multiple times).\n\
|
|
--threads=NUM-THREADS: Limits parallelism; by default, uses one thread\n\
|
|
per CPU core.\n\
|
|
--debug: Enables debug logging.\n\
|
|
--pessimize: Finds the worst seeds instead of the best seeds.\n",
|
|
+[](phosg::Arguments& args) {
|
|
if (args.get<bool>("debug")) {
|
|
static_game_data_log.min_level = phosg::LogLevel::L_DEBUG;
|
|
}
|
|
auto map_data = make_shared<string>(prs_decompress(read_input_data(args)));
|
|
auto map_file = make_shared<MapFile>(map_data);
|
|
if (!map_file->has_random_sections()) {
|
|
throw std::runtime_error("input map file does not have any random sections");
|
|
}
|
|
|
|
std::unordered_map<uint16_t, std::pair<uint8_t, int32_t>> minimize_types;
|
|
for (const auto& arg : args.get_multi<std::string>("minimize")) {
|
|
auto tokens = phosg::split(arg, ':');
|
|
if (tokens.size() == 1) {
|
|
minimize_types.emplace(std::stoul(arg, nullptr, 0), std::make_pair(0xFF, 0));
|
|
} else if (tokens.size() == 3) {
|
|
minimize_types.emplace(std::stoul(tokens[0], nullptr, 0), std::make_pair(std::stoul(tokens[1], nullptr, 0), std::stoul(tokens[2], nullptr, 0)));
|
|
} else {
|
|
throw std::runtime_error("invalid value for --minimize");
|
|
}
|
|
}
|
|
|
|
std::unordered_set<uint32_t> floor_room_ids; // (floor << 16) | room_id
|
|
for (const auto& arg : args.get_multi<std::string>("restrict-room")) {
|
|
auto tokens = phosg::split(arg, ':');
|
|
if (tokens.size() != 2) {
|
|
throw std::runtime_error("invalid value for --restrict-room");
|
|
}
|
|
uint8_t floor = std::stoul(tokens[0], nullptr, 0);
|
|
uint16_t room_id = std::stoul(tokens[1], nullptr, 0);
|
|
floor_room_ids.emplace((floor << 16) | room_id);
|
|
}
|
|
|
|
size_t num_threads = args.get<size_t>("threads", 0);
|
|
bool pessimize = args.get<bool>("pessimize");
|
|
mutex output_lock;
|
|
size_t min_counts = pessimize ? 0 : 0xFFFFFFFF;
|
|
auto thread_fn = [&](uint64_t seed, size_t) -> bool {
|
|
auto materialized = map_file->materialize_random_sections(seed);
|
|
|
|
auto is_minimize_target = [&](const MapFile::EnemySetEntry& ene) -> bool {
|
|
if (!floor_room_ids.empty()) {
|
|
uint32_t floor_room_id_key = (ene.floor << 8) | ene.room;
|
|
if (!floor_room_ids.count(floor_room_id_key)) {
|
|
return false;
|
|
}
|
|
}
|
|
if (minimize_types.empty()) {
|
|
return true;
|
|
}
|
|
auto it = minimize_types.find(ene.base_type);
|
|
if (it == minimize_types.end()) {
|
|
return false;
|
|
}
|
|
const auto& [param, value] = it->second;
|
|
switch (param) {
|
|
case 1:
|
|
return ene.param1.load() == value;
|
|
case 2:
|
|
return ene.param2.load() == value;
|
|
case 3:
|
|
return ene.param3.load() == value;
|
|
case 4:
|
|
return ene.param4.load() == value;
|
|
case 5:
|
|
return ene.param5.load() == value;
|
|
case 6:
|
|
return ene.param6.load() == value;
|
|
case 7:
|
|
return ene.param7.load() == value;
|
|
default:
|
|
return true;
|
|
}
|
|
};
|
|
|
|
size_t extra_event_count = 0;
|
|
size_t total_event_count = 0;
|
|
size_t total_enemy_set_count = 0;
|
|
size_t minimized_enemy_set_count = 0;
|
|
map<uint16_t, size_t> enemy_set_counts;
|
|
for (size_t floor = 0; floor < 0x12; floor++) {
|
|
const auto& fs = materialized->floor(floor);
|
|
if (!fs.enemy_sets || !fs.events1) {
|
|
continue;
|
|
}
|
|
total_event_count += fs.event_count;
|
|
for (size_t z = 0; z < fs.event_count; z++) {
|
|
const auto& ev = fs.events1[z];
|
|
if (ev.event_id >= 10000) {
|
|
extra_event_count++;
|
|
}
|
|
}
|
|
|
|
total_enemy_set_count += fs.enemy_set_count;
|
|
for (size_t z = 0; z < fs.enemy_set_count; z++) {
|
|
const auto& ene = fs.enemy_sets[z];
|
|
enemy_set_counts.emplace(ene.base_type, 0).first->second++;
|
|
if (is_minimize_target(ene)) {
|
|
minimized_enemy_set_count++;
|
|
}
|
|
}
|
|
}
|
|
|
|
size_t this_count = (total_event_count << 16) | minimized_enemy_set_count;
|
|
{
|
|
lock_guard g(output_lock);
|
|
if (pessimize ? (this_count >= min_counts) : (this_count <= min_counts)) {
|
|
min_counts = this_count;
|
|
string line = std::format("SEED {:08X}: event_count={} (extra={}) enemy_sets=[",
|
|
seed, total_event_count, extra_event_count);
|
|
for (const auto& it : enemy_set_counts) {
|
|
line += std::format("{:04X}={}, ", it.first, it.second);
|
|
}
|
|
line.resize(line.size() - 2);
|
|
line += std::format("] (count={}, {}={})\n",
|
|
total_enemy_set_count, pessimize ? "maximized" : "minimized", minimized_enemy_set_count);
|
|
phosg::fwritex(stdout, line);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
phosg::parallel_blocks<uint64_t>(thread_fn, 0, 0x100000000, 0x100, num_threads);
|
|
});
|
|
|
|
Action a_print_free_supermap(
|
|
"print-free-supermap", "\
|
|
print-free-supermap [--psov2] [--seed=SEED] [--episode=1|2|4]\n\
|
|
[--mode=N|B|C|S] [--difficulty=N|H|V|U] [--event=EVENT] VARIATIONS\n\
|
|
Generates and prints the specified free play supermap.\n",
|
|
+[](phosg::Arguments& args) {
|
|
Episode episode;
|
|
{
|
|
const string& episode_str = args.get<string>("episode", false);
|
|
if (episode_str == "1" || episode_str == "") {
|
|
episode = Episode::EP1;
|
|
} else if (episode_str == "2") {
|
|
episode = Episode::EP2;
|
|
} else if (episode_str == "4") {
|
|
episode = Episode::EP4;
|
|
} else {
|
|
throw std::runtime_error("invalid episode number");
|
|
}
|
|
}
|
|
|
|
GameMode mode;
|
|
{
|
|
string mode_str = phosg::tolower(args.get<string>("mode", false));
|
|
if (mode_str == "n" || mode_str == "") {
|
|
mode = GameMode::NORMAL;
|
|
} else if (mode_str == "b") {
|
|
mode = GameMode::BATTLE;
|
|
} else if (mode_str == "c") {
|
|
mode = GameMode::CHALLENGE;
|
|
} else if (mode_str == "s") {
|
|
mode = GameMode::SOLO;
|
|
} else {
|
|
throw std::runtime_error("invalid game mode");
|
|
}
|
|
}
|
|
|
|
Difficulty difficulty;
|
|
{
|
|
string mode_str = phosg::tolower(args.get<string>("difficulty", false));
|
|
if (mode_str == "n" || mode_str == "") {
|
|
difficulty = Difficulty::NORMAL;
|
|
} else if (mode_str == "h") {
|
|
difficulty = Difficulty::HARD;
|
|
} else if (mode_str == "v") {
|
|
difficulty = Difficulty::VERY_HARD;
|
|
} else if (mode_str == "u") {
|
|
difficulty = Difficulty::ULTIMATE;
|
|
} else {
|
|
throw std::runtime_error("invalid difficulty level");
|
|
}
|
|
}
|
|
|
|
uint8_t event = args.get<uint8_t>("event", 0, phosg::Arguments::IntFormat::HEX);
|
|
uint32_t random_seed = args.get<uint32_t>("seed", phosg::random_object<uint32_t>(), phosg::Arguments::IntFormat::HEX);
|
|
|
|
string variations_str = args.get<string>(1);
|
|
Variations variations;
|
|
for (size_t z = 0; z < variations_str.size(); z++) {
|
|
if (z & 1) {
|
|
variations.entries[z >> 1].entities = phosg::value_for_hex_char(variations_str[z]);
|
|
} else {
|
|
variations.entries[z >> 1].layout = phosg::value_for_hex_char(variations_str[z]);
|
|
}
|
|
}
|
|
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->load_config_early();
|
|
s->clear_file_caches();
|
|
s->load_patch_indexes();
|
|
s->load_set_data_tables();
|
|
s->load_maps();
|
|
|
|
shared_ptr<RandomGenerator> rand_crypt;
|
|
if (args.get<bool>("--psov2")) {
|
|
rand_crypt = std::make_shared<MT19937Generator>(random_seed);
|
|
} else {
|
|
rand_crypt = std::make_shared<PSOV2Encryption>(random_seed);
|
|
}
|
|
auto sdt = s->set_data_table(get_cli_version(args, Version::BB_V4), episode, mode, difficulty);
|
|
auto supermaps = s->supermaps_for_variations(episode, mode, difficulty, variations);
|
|
MapState map_state(0, difficulty, event, random_seed, MapState::DEFAULT_RARE_ENEMIES, rand_crypt, supermaps);
|
|
map_state.verify();
|
|
map_state.print(stdout);
|
|
});
|
|
|
|
Action a_check_quests(
|
|
"check-quests", nullptr,
|
|
+[](phosg::Arguments& args) {
|
|
check_quest_opcode_definitions();
|
|
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->is_debug = true;
|
|
s->load_config_early();
|
|
s->clear_file_caches();
|
|
s->load_patch_indexes();
|
|
s->load_set_data_tables();
|
|
s->load_maps();
|
|
s->load_quest_index(true);
|
|
|
|
bool reassemble_scripts = args.get<bool>("reassemble-scripts");
|
|
bool reassemble_maps = args.get<bool>("reassemble-maps");
|
|
if (reassemble_scripts || reassemble_maps) {
|
|
for (const auto& [_, q] : s->quest_index->quests_by_number) {
|
|
for (const auto& [_, vq] : q->versions) {
|
|
if (reassemble_maps) {
|
|
auto dat = prs_decompress(*vq->dat_contents);
|
|
auto serialized = vq->map_file->serialize();
|
|
if (dat != serialized) {
|
|
phosg::log_info_f("... DISASSEMBLY:");
|
|
phosg::fwritex(stdout, vq->map_file->disassemble(false, vq->meta.version));
|
|
phosg::log_info_f("... BINDIFF:");
|
|
phosg::print_binary_diff(
|
|
stdout, dat.data(), dat.size(), serialized.data(), serialized.size(), isatty(fileno(stdout)));
|
|
phosg::log_info_f("... {} {} {} ({}) MAP FAILED",
|
|
phosg::name_for_enum(vq->meta.version),
|
|
name_for_language(vq->meta.language),
|
|
vq->dat_filename(),
|
|
vq->meta.name);
|
|
throw std::runtime_error("re-serialized map file differs from original");
|
|
}
|
|
phosg::log_info_f("... {} {} {} ({}) MAP OK", phosg::name_for_enum(vq->meta.version), name_for_language(vq->meta.language), vq->dat_filename(), vq->meta.name);
|
|
}
|
|
if (reassemble_scripts) {
|
|
auto bin = prs_decompress(*vq->bin_contents);
|
|
auto disassembled = disassemble_quest_script(
|
|
bin.data(), bin.size(), vq->meta.version, vq->meta.language, vq->map_file, false, false);
|
|
auto reassembly = disassemble_quest_script(
|
|
bin.data(), bin.size(), vq->meta.version, vq->meta.language, vq->map_file, true, false);
|
|
string include_dir = phosg::dirname(vq->bin_filename());
|
|
AssembledQuestScript assembled;
|
|
try {
|
|
assembled = assemble_quest_script(
|
|
reassembly,
|
|
{"system/quests/includes"},
|
|
{"system/quests/includes", "system/client-functions/System"},
|
|
false);
|
|
if (vq->json_contents) {
|
|
assembled.meta.apply_json_overrides(*vq->json_contents);
|
|
}
|
|
if (assembled.data != bin) {
|
|
throw std::runtime_error("Reassembled quest script does not match original");
|
|
}
|
|
// Don't check quest number, since we override it based on the filename
|
|
if (assembled.meta.version != vq->meta.version) {
|
|
throw std::runtime_error(std::format("Reassembled quest version ({}) does not match original ({})",
|
|
phosg::name_for_enum(assembled.meta.version), phosg::name_for_enum(vq->meta.version)));
|
|
}
|
|
if (assembled.meta.language != vq->meta.language) {
|
|
throw std::runtime_error(std::format("Reassembled quest language ({}) does not match original ({})",
|
|
name_for_language(assembled.meta.language), name_for_language(vq->meta.language)));
|
|
}
|
|
if (assembled.meta.episode != vq->meta.episode) {
|
|
throw std::runtime_error(std::format("Reassembled quest episode ({}) does not match original ({})",
|
|
name_for_episode(assembled.meta.episode), name_for_episode(vq->meta.episode)));
|
|
}
|
|
if (assembled.meta.joinable != vq->meta.joinable) {
|
|
throw std::runtime_error(std::format("Reassembled quest joinable ({}) does not match original ({})",
|
|
assembled.meta.joinable, vq->meta.joinable));
|
|
}
|
|
if (assembled.meta.max_players != vq->meta.max_players) {
|
|
throw std::runtime_error(std::format("Reassembled quest max_players ({}) does not match original ({})",
|
|
assembled.meta.max_players, vq->meta.max_players));
|
|
}
|
|
if (assembled.meta.name != vq->meta.name) {
|
|
throw std::runtime_error(std::format("Reassembled quest name ({}) does not match original ({})",
|
|
assembled.meta.name, vq->meta.name));
|
|
}
|
|
if (assembled.meta.short_description != vq->meta.short_description) {
|
|
throw std::runtime_error(std::format("Reassembled quest short description ({}) does not match original ({})",
|
|
assembled.meta.short_description, vq->meta.short_description));
|
|
}
|
|
if (assembled.meta.long_description != vq->meta.long_description) {
|
|
throw std::runtime_error(std::format("Reassembled quest long description ({}) does not match original ({})",
|
|
assembled.meta.long_description, vq->meta.long_description));
|
|
}
|
|
} catch (const std::exception& e) {
|
|
phosg::log_error_f("================ DISASSEMBLY:");
|
|
phosg::fwritex(stderr, disassembled);
|
|
phosg::log_error_f("================ REASSEMBLY:");
|
|
phosg::fwritex(stderr, reassembly);
|
|
if (!assembled.data.empty()) {
|
|
phosg::log_error_f("================ BINDIFF:");
|
|
phosg::print_binary_diff(stderr, bin.data(), bin.size(), assembled.data.data(), assembled.data.size(), isatty(fileno(stderr)), 3, 0);
|
|
}
|
|
phosg::log_info_f("... {} {} {} ({}) SCRIPT FAILED", phosg::name_for_enum(vq->meta.version), name_for_language(vq->meta.language), vq->bin_filename(), vq->meta.name);
|
|
throw;
|
|
}
|
|
phosg::log_info_f("... {} {} {} ({}) SCRIPT OK", phosg::name_for_enum(vq->meta.version), name_for_language(vq->meta.language), vq->bin_filename(), vq->meta.name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
Action a_check_ep3_maps(
|
|
"check-ep3-maps", nullptr,
|
|
+[](phosg::Arguments& args) {
|
|
config_log.info_f("Collecting Episode 3 data");
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->is_debug = true;
|
|
s->load_ep3_maps(true);
|
|
});
|
|
|
|
Action a_check_client_functions(
|
|
"check-client-functions", nullptr,
|
|
+[](phosg::Arguments&) {
|
|
set_all_log_levels(phosg::LogLevel::L_DEBUG);
|
|
ClientFunctionIndex index("system/client-functions", true);
|
|
phosg::fwrite_fmt(stdout, "All client functions compiled\n");
|
|
});
|
|
|
|
Action a_parse_object_graph(
|
|
"parse-object-graph", nullptr, +[](phosg::Arguments& args) {
|
|
uint32_t root_object_address = args.get<uint32_t>("root", phosg::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",
|
|
+[](phosg::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);
|
|
phosg::fwrite_fmt(stdout, "{}\n", serial_number);
|
|
});
|
|
Action a_generate_all_dc_serial_numbers(
|
|
"dc-serial-number-generator-test", nullptr,
|
|
+[](phosg::Arguments& args) {
|
|
size_t num_threads = args.get<size_t>("threads", 0);
|
|
|
|
vector<unordered_set<uint32_t>> serial_numbers;
|
|
serial_numbers.resize(9);
|
|
|
|
DCSerialNumberIterator iter;
|
|
uint32_t serial_number;
|
|
size_t num_serial_numbers = 0;
|
|
while ((serial_number = iter.next()) != 0) {
|
|
serial_numbers[iter.domain * 3 + iter.subdomain].emplace(serial_number);
|
|
if (((++num_serial_numbers) % 0x10000) == 0) {
|
|
phosg::fwrite_fmt(stderr, "... {:08X} (domain={:02X}, subdomain={:02X}, index2={:04X}, index3={:04X}) counts=[{}, {}, {}, {}, {}, {}, {}, {}, {}]\n",
|
|
num_serial_numbers, iter.domain, iter.subdomain, iter.index2, iter.index3,
|
|
serial_numbers[0].size(), serial_numbers[1].size(), serial_numbers[2].size(),
|
|
serial_numbers[3].size(), serial_numbers[4].size(), serial_numbers[5].size(),
|
|
serial_numbers[6].size(), serial_numbers[7].size(), serial_numbers[8].size());
|
|
}
|
|
}
|
|
|
|
array<atomic<size_t>, 9> found_counts = {0, 0, 0, 0, 0, 0, 0, 0, 0};
|
|
atomic<uint64_t> num_mismatches = 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++) {
|
|
bool is_valid = dc_serial_number_is_valid_fast(serial_number, domain, subdomain);
|
|
bool was_iterated = serial_numbers[domain * 3 + subdomain].count(serial_number);
|
|
if (is_valid != was_iterated) {
|
|
lock_guard g(output_lock);
|
|
phosg::fwrite_fmt(stdout, "Mismatch at {:08X} (domain={}, subdomain={}): is_valid={}, was_iterated={}\n",
|
|
serial_number, domain, subdomain, is_valid ? "true" : "false", was_iterated ? "true" : "false");
|
|
} else if (is_valid && was_iterated) {
|
|
found_counts[domain * 3 + subdomain]++;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
auto progress_fn = [&](uint64_t, uint64_t, uint64_t current_value, uint64_t) -> void {
|
|
phosg::fwrite_fmt(stderr, "... {:08X} {} mismatches; counts: [{}/{}, {}/{}, {}/{}, {}/{}, {}/{}, {}/{}, {}/{}, {}/{}, {}/{}]\r", current_value, num_mismatches.load(),
|
|
found_counts[0].load(), serial_numbers[0].size(),
|
|
found_counts[1].load(), serial_numbers[1].size(),
|
|
found_counts[2].load(), serial_numbers[2].size(),
|
|
found_counts[3].load(), serial_numbers[3].size(),
|
|
found_counts[4].load(), serial_numbers[4].size(),
|
|
found_counts[5].load(), serial_numbers[5].size(),
|
|
found_counts[6].load(), serial_numbers[6].size(),
|
|
found_counts[7].load(), serial_numbers[7].size(),
|
|
found_counts[8].load(), serial_numbers[8].size());
|
|
};
|
|
phosg::parallel_blocks<uint64_t>(thread_fn, 0, 0x100000000, 0x1000, num_threads, progress_fn);
|
|
|
|
if (num_mismatches > 0) {
|
|
throw logic_error("mismatches occurred during test");
|
|
}
|
|
});
|
|
|
|
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",
|
|
+[](phosg::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)) {
|
|
phosg::fwrite_fmt(stdout, "{} is valid in domain {} subdomain {}\n", serial_number_str, domain, subdomain);
|
|
num_valid_subdomains++;
|
|
}
|
|
}
|
|
}
|
|
if (num_valid_subdomains == 0) {
|
|
phosg::fwrite_fmt(stdout, "{} is not valid in any domain\n", serial_number_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",
|
|
+[](phosg::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_address_translator(
|
|
"address-translator", nullptr, +[](phosg::Arguments& args) {
|
|
const string& dir = args.get<string>(1, false);
|
|
if (dir.empty() || (dir == "-")) {
|
|
throw invalid_argument("a directory name is required");
|
|
}
|
|
run_address_translator(dir, args.get<string>(2, false), args.get<string>(3, false));
|
|
});
|
|
|
|
Action a_diff_executables(
|
|
"diff-executables", nullptr, +[](phosg::Arguments& args) {
|
|
const string& a_filename = args.get<string>(1);
|
|
const string& b_filename = args.get<string>(2);
|
|
bool show_pre = args.get<bool>("show-pre");
|
|
bool a_is_dol = a_filename.ends_with(".dol");
|
|
bool b_is_dol = b_filename.ends_with(".dol");
|
|
bool a_is_xbe = a_filename.ends_with(".xbe");
|
|
bool b_is_xbe = b_filename.ends_with(".xbe");
|
|
std::vector<DiffEntry> result;
|
|
if (a_is_dol && b_is_dol) {
|
|
result = diff_dol_files(a_filename, b_filename);
|
|
} else if (a_is_xbe && b_is_xbe) {
|
|
result = diff_xbe_files(a_filename, b_filename);
|
|
} else {
|
|
throw runtime_error("the two files are not the same type of executable, or are neither dol nor xbe");
|
|
}
|
|
for (const auto& it : result) {
|
|
string b_str = phosg::format_data_string(it.b_data, nullptr, phosg::FormatDataStringFlags::HEX_ONLY);
|
|
if (show_pre) {
|
|
string a_str = phosg::format_data_string(it.a_data, nullptr, phosg::FormatDataStringFlags::HEX_ONLY);
|
|
phosg::fwrite_fmt(stdout, "{:08X}: {} => {}\n", it.address, a_str, b_str);
|
|
} else {
|
|
phosg::fwrite_fmt(stdout, "{:08X} {}\n", it.address, b_str);
|
|
}
|
|
}
|
|
});
|
|
|
|
Action a_generate_hangame_creds(
|
|
"generate-hangame-creds", nullptr, +[](phosg::Arguments& args) {
|
|
const string& user_id = args.get<string>(1);
|
|
const string& token = args.get<string>(2);
|
|
const string& unused = args.get<string>(3, false);
|
|
string hex = phosg::format_data_string(encode_psobb_hangame_credentials(user_id, token, unused));
|
|
phosg::fwrite_fmt(stdout, "psobb.exe 1196310600 {}\n", hex);
|
|
});
|
|
|
|
Action a_format_ep3_battle_record(
|
|
"format-ep3-battle-record", nullptr, +[](phosg::Arguments& args) {
|
|
string data = read_input_data(args);
|
|
Episode3::BattleRecord rec(data);
|
|
rec.print(stdout);
|
|
});
|
|
|
|
Action a_replay_ep3_battle_commands(
|
|
"replay-ep3-battle-commands", nullptr, +[](phosg::Arguments& args) {
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->load_ep3_cards();
|
|
s->load_ep3_maps();
|
|
|
|
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 : phosg::split(input, '\n')) {
|
|
string data = phosg::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_stream = nullptr,
|
|
.rand_crypt = make_shared<MT19937Generator>(seed),
|
|
.tournament = nullptr,
|
|
.trap_card_ids = {},
|
|
.output_queue = nullptr,
|
|
};
|
|
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);
|
|
phosg::parallel_blocks<int64_t>(run_replay, 0, 0x100000000, 0x1000, num_threads);
|
|
}
|
|
});
|
|
|
|
Action a_replay_ep3_battle_record(
|
|
"replay-ep3-battle-record", nullptr, +[](phosg::Arguments& args) {
|
|
auto record_data = read_input_data(args);
|
|
if (args.get<bool>("compressed")) {
|
|
record_data = prs_decompress(record_data);
|
|
}
|
|
auto rec = make_shared<Episode3::BattleRecord>(record_data);
|
|
|
|
bool use_color = isatty(fileno(stdout));
|
|
|
|
auto s = make_shared<ServerState>(get_config_filename(args));
|
|
s->load_ep3_cards();
|
|
s->load_ep3_maps();
|
|
|
|
bool is_nte = rec->get_behavior_flags() & Episode3::BehaviorFlag::IS_TRIAL_EDITION;
|
|
auto output_queue = std::make_shared<std::deque<std::string>>();
|
|
Episode3::Server::Options options = {
|
|
.card_index = s->ep3_card_index,
|
|
.map_index = s->ep3_map_index,
|
|
.behavior_flags = rec->get_behavior_flags() & ~(Episode3::BehaviorFlag::LOG_COMMANDS_IF_LOBBY_MISSING),
|
|
.opt_rand_stream = make_shared<phosg::StringReader>(rec->get_random_stream()),
|
|
.rand_crypt = make_shared<DisabledRandomGenerator>(),
|
|
.tournament = nullptr,
|
|
.trap_card_ids = {},
|
|
.output_queue = output_queue,
|
|
};
|
|
auto server = make_shared<Episode3::Server>(nullptr, std::move(options));
|
|
server->init();
|
|
|
|
// Ignore commands generated by the server when it's constructed (these are not included in the battle record)
|
|
output_queue->clear();
|
|
|
|
std::array<bool, 4> players_present = {false, false, false, false};
|
|
for (const auto& ev : rec->get_all_events()) {
|
|
switch (ev.type) {
|
|
case Episode3::BattleRecord::Event::Type::SET_INITIAL_PLAYERS:
|
|
ev.print(stdout);
|
|
for (const auto& player : ev.players) {
|
|
players_present.at(player.lobby_data.client_id) = true;
|
|
phosg::fwrite_fmt(stderr, "Player {} is present\n", player.lobby_data.client_id.load());
|
|
}
|
|
break;
|
|
case Episode3::BattleRecord::Event::Type::PLAYER_JOIN:
|
|
case Episode3::BattleRecord::Event::Type::PLAYER_LEAVE:
|
|
case Episode3::BattleRecord::Event::Type::CHAT_MESSAGE:
|
|
case Episode3::BattleRecord::Event::Type::GAME_COMMAND:
|
|
case Episode3::BattleRecord::Event::Type::EP3_GAME_COMMAND:
|
|
ev.print(stdout);
|
|
break;
|
|
case Episode3::BattleRecord::Event::Type::BATTLE_COMMAND:
|
|
// Ignore the map command (handled separately) and 6xB4x4B (only needed when a lobby is present)
|
|
if (ev.data.empty() || (static_cast<uint8_t>(ev.data[0]) == 0xB6) || (ev.data.at(4) == 0x4B)) {
|
|
ev.print(stdout);
|
|
} else {
|
|
if (use_color) {
|
|
phosg::print_color_escape(stdout, phosg::TerminalFormat::FG_RED, phosg::TerminalFormat::BOLD, phosg::TerminalFormat::END);
|
|
}
|
|
ev.print(stdout);
|
|
if (use_color) {
|
|
phosg::print_color_escape(stdout, phosg::TerminalFormat::NORMAL, phosg::TerminalFormat::END);
|
|
fflush(stdout);
|
|
}
|
|
if (output_queue->empty()) {
|
|
phosg::fwrite_fmt(stderr, "Output queue is empty, but expected battle command:\n");
|
|
phosg::print_data(stderr, ev.data, 0, phosg::FormatDataFlags::OFFSET_16_BITS | phosg::FormatDataFlags::PRINT_ASCII);
|
|
throw std::runtime_error("Output did not match expectations");
|
|
}
|
|
// Hack: don't check the last field in 6xB4x46 since it contains a timestamp on non-NTE
|
|
bool matched = false;
|
|
if ((ev.data.at(4) == 0x46) && !is_nte) {
|
|
auto received_cmd = check_size_t<G_ServerVersionStrings_Ep3_6xB4x46>(output_queue->front());
|
|
auto expected_cmd = check_size_t<G_ServerVersionStrings_Ep3_6xB4x46>(ev.data);
|
|
received_cmd.date_str2.clear(0);
|
|
expected_cmd.date_str2.clear(0);
|
|
matched = !memcmp(&received_cmd, &expected_cmd, sizeof(received_cmd));
|
|
} else {
|
|
matched = (output_queue->front() == ev.data);
|
|
}
|
|
if (!matched) {
|
|
phosg::fwrite_fmt(stderr, "Output queue front did not match expected command; expected:\n");
|
|
phosg::print_data(stderr, ev.data, 0, phosg::FormatDataFlags::OFFSET_16_BITS | phosg::FormatDataFlags::PRINT_ASCII);
|
|
phosg::fwrite_fmt(stderr, "Received:\n");
|
|
phosg::print_data(stderr, output_queue->front(), 0, ev.data, phosg::FormatDataFlags::OFFSET_16_BITS | phosg::FormatDataFlags::PRINT_ASCII);
|
|
throw std::runtime_error("Output did not match expectations");
|
|
}
|
|
output_queue->pop_front();
|
|
}
|
|
break;
|
|
case Episode3::BattleRecord::Event::Type::SERVER_DATA_COMMAND:
|
|
if (use_color) {
|
|
phosg::print_color_escape(stdout, phosg::TerminalFormat::FG_GREEN, phosg::TerminalFormat::BOLD, phosg::TerminalFormat::END);
|
|
}
|
|
ev.print(stdout);
|
|
if (use_color) {
|
|
phosg::print_color_escape(stdout, phosg::TerminalFormat::NORMAL, phosg::TerminalFormat::END);
|
|
fflush(stdout);
|
|
}
|
|
if (!output_queue->empty()) {
|
|
phosg::fwrite_fmt(stderr, "Received extra output after preceding SERVER_DATA event:\n");
|
|
phosg::print_data(stderr, output_queue->front());
|
|
throw std::runtime_error("Output did not match expectations");
|
|
}
|
|
// Hack: Set the CPU player flag if the player isn't present in the recording (normally this is done by
|
|
// checking the Lobby, but there's no Lobby during a replay)
|
|
if (ev.data.at(4) == 0x1B) {
|
|
string mutable_data = ev.data;
|
|
auto& cmd = check_size_t<G_SetPlayerName_Ep3_CAx1B>(mutable_data);
|
|
cmd.entry.is_cpu_player = !players_present.at(cmd.entry.client_id);
|
|
phosg::fwrite_fmt(stderr, "Overriding is_cpu_player with {}\n", cmd.entry.is_cpu_player ? "true" : "false");
|
|
server->on_server_data_input(nullptr, mutable_data);
|
|
} else {
|
|
server->on_server_data_input(nullptr, ev.data);
|
|
}
|
|
break;
|
|
default:
|
|
throw std::runtime_error("unknown event type: {}");
|
|
}
|
|
}
|
|
if (!output_queue->empty()) {
|
|
phosg::fwrite_fmt(stderr, "Received extra output after recording completed:\n");
|
|
phosg::print_data(stderr, output_queue->front());
|
|
throw std::runtime_error("Output did not match expectations");
|
|
}
|
|
});
|
|
|
|
Action a_disassemble_ep3_battle_record(
|
|
"disassemble-ep3-battle-record", nullptr, +[](phosg::Arguments& args) {
|
|
Episode3::BattleRecord(read_input_data(args)).print(stdout);
|
|
});
|
|
|
|
Action a_run_server_replay_log(
|
|
"", nullptr, +[](phosg::Arguments& args) {
|
|
{
|
|
string build_date = phosg::format_time(BUILD_TIMESTAMP);
|
|
config_log.info_f("newserv {} compiled at {}", GIT_REVISION_HASH, build_date);
|
|
}
|
|
|
|
if (!std::filesystem::is_directory("system/players")) {
|
|
config_log.info_f("Players directory does not exist; creating it");
|
|
std::filesystem::create_directories("system/players");
|
|
}
|
|
|
|
const auto& replay_log_filenames = args.get_multi<string>("replay-log");
|
|
|
|
#ifndef PHOSG_WINDOWS
|
|
signal(SIGPIPE, SIG_IGN);
|
|
#endif
|
|
if (!phosg::is_windows() && isatty(fileno(stderr))) {
|
|
use_terminal_colors = true;
|
|
}
|
|
|
|
auto state = make_shared<ServerState>(get_config_filename(args), !replay_log_filenames.empty());
|
|
if (args.get<bool>("debug")) {
|
|
state->is_debug = true;
|
|
}
|
|
state->load_all(true);
|
|
|
|
if (state->dns_server_port) {
|
|
if (!state->dns_server_addr.empty()) {
|
|
config_log.info_f("Starting DNS server on {}:{}", state->dns_server_addr, state->dns_server_port);
|
|
} else {
|
|
config_log.info_f("Starting DNS server on port {}", state->dns_server_port);
|
|
}
|
|
state->dns_server = make_shared<DNSServer>(state);
|
|
state->dns_server->listen(state->dns_server_addr, state->dns_server_port);
|
|
} else {
|
|
config_log.info_f("DNS server is disabled");
|
|
}
|
|
|
|
shared_ptr<ServerShell> shell;
|
|
shared_ptr<SignalWatcher> signal_watcher;
|
|
shared_ptr<ReplaySession> last_running_replay;
|
|
if (!replay_log_filenames.empty()) {
|
|
config_log.info_f("Starting game server");
|
|
state->game_server = make_shared<GameServer>(state);
|
|
|
|
// TODO: Do this properly via a config option, you lazy bum
|
|
state->dol_file_index = make_shared<DOLFileIndex>();
|
|
|
|
auto run_replays = [&]() -> asio::awaitable<void> {
|
|
try {
|
|
for (const auto& log_filename : replay_log_filenames) {
|
|
phosg::log_info_f("[Replay] {} ...", log_filename);
|
|
auto log_f = phosg::fopen_shared(log_filename, "rt");
|
|
last_running_replay = make_shared<ReplaySession>(state, log_f.get());
|
|
co_await last_running_replay->run();
|
|
if (last_running_replay->failed()) {
|
|
phosg::log_error_f("[Replay] {} failed", log_filename);
|
|
break;
|
|
}
|
|
phosg::log_info_f("[Replay] {} OK", log_filename);
|
|
state->reset_between_replays();
|
|
}
|
|
phosg::log_info_f("[Replay] All replays complete");
|
|
} catch (const std::exception& e) {
|
|
phosg::log_info_f("[Replay] Replays failed: {}", e.what());
|
|
}
|
|
if (!last_running_replay->failed()) {
|
|
last_running_replay.reset();
|
|
}
|
|
state->io_context->stop();
|
|
};
|
|
asio::co_spawn(*state->io_context, run_replays, asio::detached);
|
|
|
|
} else {
|
|
config_log.info_f("Opening sockets");
|
|
for (const auto& [_, pc] : state->name_to_port_config) {
|
|
if (!state->game_server.get()) {
|
|
config_log.info_f("Starting game server");
|
|
state->game_server = make_shared<GameServer>(state);
|
|
}
|
|
string spec = std::format("TG-{}-{}-{}-{}",
|
|
pc->port, phosg::name_for_enum(pc->version), pc->name, phosg::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() || !state->ppp_raw_addresses.empty()) {
|
|
config_log.info_f("Starting IP/PPP stack simulator");
|
|
state->ip_stack_simulator = make_shared<IPStackSimulator>(state);
|
|
for (const auto& it : state->ip_stack_addresses) {
|
|
auto netloc = phosg::parse_netloc(it);
|
|
string spec = (netloc.second == 0) ? ("T-IPS-" + netloc.first) : std::format("T-IPS-{}", netloc.second);
|
|
state->ip_stack_simulator->listen(
|
|
spec, netloc.first, netloc.second, VirtualNetworkProtocol::ETHERNET_TAPSERVER);
|
|
}
|
|
for (const auto& it : state->ppp_stack_addresses) {
|
|
auto netloc = phosg::parse_netloc(it);
|
|
string spec = (netloc.second == 0) ? ("T-PPPST-" + netloc.first) : std::format("T-PPPST-{}", netloc.second);
|
|
state->ip_stack_simulator->listen(
|
|
spec, netloc.first, netloc.second, VirtualNetworkProtocol::HDLC_TAPSERVER);
|
|
}
|
|
for (const auto& it : state->ppp_raw_addresses) {
|
|
auto netloc = phosg::parse_netloc(it);
|
|
string spec = (netloc.second == 0) ? ("T-PPPSR-" + netloc.first) : std::format("T-PPPSR-{}", netloc.second);
|
|
state->ip_stack_simulator->listen(
|
|
spec, netloc.first, netloc.second, VirtualNetworkProtocol::HDLC_RAW);
|
|
if (netloc.second) {
|
|
if (state->local_address == 0 && state->external_address == 0) {
|
|
config_log.info_f(
|
|
"Cannot generate Devolution phone numbers for {} because LocalAddress and ExternalAddress are not specified in the configuration",
|
|
spec);
|
|
} else if (state->local_address == 0) {
|
|
config_log.info_f(
|
|
"Note: The Devolution phone number for {} is {} (external)",
|
|
spec, devolution_phone_number_for_netloc(state->external_address, netloc.second));
|
|
} else if (state->external_address == 0) {
|
|
config_log.info_f(
|
|
"Note: The Devolution phone number for {} is {} (local)",
|
|
spec, devolution_phone_number_for_netloc(state->local_address, netloc.second));
|
|
} else if (state->local_address == state->external_address) {
|
|
config_log.info_f(
|
|
"Note: The Devolution phone number for {} is {} (local+external)",
|
|
spec, devolution_phone_number_for_netloc(state->local_address, netloc.second));
|
|
} else {
|
|
config_log.info_f(
|
|
"Note: The Devolution phone numbers for {} are {} (local) and {} (external)",
|
|
spec,
|
|
devolution_phone_number_for_netloc(state->local_address, netloc.second),
|
|
devolution_phone_number_for_netloc(state->external_address, netloc.second));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!state->http_addresses.empty() || !state->http_addresses.empty()) {
|
|
config_log.info_f("Starting HTTP server");
|
|
state->http_server = make_shared<HTTPServer>(state);
|
|
for (const auto& it : state->http_addresses) {
|
|
auto netloc = phosg::parse_netloc(it);
|
|
state->http_server->listen(netloc.first, netloc.second);
|
|
}
|
|
}
|
|
|
|
#ifndef PHOSG_WINDOWS
|
|
config_log.info_f("Enabling signal watcher");
|
|
signal_watcher = make_shared<SignalWatcher>(state);
|
|
#endif
|
|
}
|
|
|
|
#ifndef PHOSG_WINDOWS
|
|
if (!state->username.empty()) {
|
|
config_log.info_f("Switching to user {}", state->username);
|
|
drop_privileges(state->username);
|
|
}
|
|
#endif
|
|
|
|
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_log_filenames.empty();
|
|
}
|
|
|
|
config_log.info_f("Ready");
|
|
if (should_run_shell) {
|
|
shell = make_shared<ServerShell>(state);
|
|
}
|
|
|
|
state->io_context->run();
|
|
config_log.info_f("Normal shutdown");
|
|
|
|
if (last_running_replay) {
|
|
throw runtime_error("Replay failed");
|
|
}
|
|
});
|
|
|
|
void print_version_info() {
|
|
string build_date = phosg::format_time(BUILD_TIMESTAMP);
|
|
phosg::fwrite_fmt(stderr, "newserv-{} built {} UTC\n", GIT_REVISION_HASH, build_date);
|
|
}
|
|
|
|
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) {
|
|
phosg::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&) {
|
|
phosg::log_error_f("Unknown or invalid action; try --help");
|
|
return 1;
|
|
}
|
|
a->run(args);
|
|
return 0;
|
|
}
|