#include "subtitles.h" #include "common/goos/ParseHelpers.h" #include "common/goos/Reader.h" #include "common/util/FileUtil.h" #include "common/util/json_util.h" #include "third-party/fmt/core.h" static const std::unordered_map s_text_ver_enum_map = { {"jak1-v1", GameTextVersion::JAK1_V1}, {"jak1-v2", GameTextVersion::JAK1_V2}}; // TODO - why not just return the inputs instead of passing in an empty one? void open_text_project(const std::string& kind, const std::string& filename, std::unordered_map>& inputs) { goos::Reader reader; auto& proj = reader.read_from_file({filename}).as_pair()->cdr.as_pair()->car; if (!proj.is_pair() || !proj.as_pair()->car.is_symbol() || proj.as_pair()->car.as_symbol()->name != kind) { throw std::runtime_error(fmt::format("invalid {} project", kind)); } goos::for_each_in_list(proj.as_pair()->cdr, [&](const goos::Object& o) { if (!o.is_pair()) { throw std::runtime_error(fmt::format("invalid entry in {} project", kind)); } auto& ver = o.as_pair()->car.as_symbol()->name; auto& in = o.as_pair()->cdr.as_pair()->car.as_string()->data; if (s_text_ver_enum_map.count(ver) == 0) { throw std::runtime_error(fmt::format("unknown text version {}", ver)); } inputs[s_text_ver_enum_map.at(ver)].push_back(in); }); } int64_t get_int(const goos::Object& obj) { if (obj.is_int()) { return obj.integer_obj.value; } throw std::runtime_error(obj.print() + " was supposed to be an integer, but isn't"); } const goos::Object& car(const goos::Object& x) { if (!x.is_pair()) { throw std::runtime_error("invalid pair"); } return x.as_pair()->car; } const goos::Object& cdr(const goos::Object& x) { if (!x.is_pair()) { throw std::runtime_error("invalid pair"); } return x.as_pair()->cdr; } std::string get_string(const goos::Object& x) { if (x.is_string()) { return x.as_string()->data; } throw std::runtime_error(x.print() + " was supposed to be a string, but isn't"); } /*! * Parse a game text file. * Information is added to the game text database. * * The file should begin with (language-id x y z...) with the given language IDs. * Each entry should be (id "line for 1st language" "line for 2nd language" ...) * This adds the text line to each of the specified languages. */ void parse_text(const goos::Object& data, GameTextVersion text_ver, GameTextDB& db) { auto font = get_font_bank(text_ver); std::vector> banks; std::string possible_group_name; for_each_in_list(data.as_pair()->cdr, [&](const goos::Object& obj) { if (obj.is_pair()) { auto& head = car(obj); if (head.is_symbol("language-id")) { if (banks.size() != 0) { throw std::runtime_error("Languages have been set multiple times."); } if (cdr(obj).is_empty_list()) { throw std::runtime_error("At least one language must be set."); } if (possible_group_name.empty()) { throw std::runtime_error("Text group must be set before languages."); } for_each_in_list(cdr(obj), [&](const goos::Object& obj) { auto lang = get_int(obj); if (!db.bank_exists(possible_group_name, lang)) { // database has no lang in this group yet banks.push_back(db.add_bank(possible_group_name, std::make_shared(lang))); } else { banks.push_back(db.bank_by_id(possible_group_name, lang)); } }); } else if (head.is_symbol("group-name")) { if (!possible_group_name.empty()) { throw std::runtime_error("group-name has been set multiple times."); } possible_group_name = get_string(car(cdr(obj))); if (possible_group_name.empty()) { throw std::runtime_error("invalid group-name."); } if (!cdr(cdr(obj)).is_empty_list()) { throw std::runtime_error("group-name has too many arguments"); } } else if (head.is_symbol("credits")) { // parse a "credits" object. it's a list of lines where the ID automatically increments, and // empty lines are skipped if (banks.size() == 0) { throw std::runtime_error("At least one language must be set before defining entries."); } if (cdr(obj).is_empty_list() || cdr(cdr(obj)).is_empty_list() || !car(cdr(obj)).is_symbol(":begin") || !car(cdr(cdr(obj))).is_int()) { throw std::runtime_error("Invalid credits begin param"); } const auto& it = cdr(cdr(obj)); int begin_id = car(it).as_int(); int id = begin_id - 1; for_each_in_list(cdr(it), [&](const goos::Object& entry) { ++id; if (entry.is_string()) { if (entry.as_string()->data.empty()) { // empty string! just advance return; } auto line = font->convert_utf8_to_game(entry.as_string()->data); // add to all langs for (auto& bank : banks) { bank->set_line(id, line); } } else if (entry.is_pair()) { int b_i = 0; for_each_in_list(entry, [&](const goos::Object& entry) { if (entry.is_string()) { if (b_i >= int(banks.size())) { throw std::runtime_error(fmt::format("Too many strings in text id #x{:x}", id)); } auto line = font->convert_utf8_to_game(entry.as_string()->data); banks[b_i++]->set_line(id, line); } else { throw std::runtime_error(fmt::format("Non-string value in text id #x{:x}", id)); } }); if (b_i != int(banks.size())) { throw std::runtime_error( fmt::format("Not enough strings specified in text id #x{:x}", id)); } } else { throw std::runtime_error(fmt::format("Non-string value in text id #x{:x}", id)); } }); } else if (head.is_int()) { if (banks.size() == 0) { throw std::runtime_error("At least one language must be set before defining entries."); } int i = 0; int id = head.as_int(); for_each_in_list(cdr(obj), [&](const goos::Object& entry) { if (entry.is_string()) { if (i >= int(banks.size())) { throw std::runtime_error(fmt::format("Too many strings in text id #x{:x}", id)); } auto line = font->convert_utf8_to_game(entry.as_string()->data); banks[i++]->set_line(id, line); } else { throw std::runtime_error(fmt::format("Non-string value in text id #x{:x}", id)); } }); if (i != int(banks.size())) { throw std::runtime_error( fmt::format("Not enough strings specified in text id #x{:x}", id)); } } else { throw std::runtime_error("Invalid game text file entry: " + head.print()); } } else { throw std::runtime_error("Invalid game text file"); } }); if (banks.size() == 0) { throw std::runtime_error("At least one language must be set."); } } /*! * Parse a game subtitle file. * Information is added to the game subtitles database. * * The file should begin with (language-id x y z...) for the given language IDs. * Each scene should be (scene-name ... ) * This adds the subtitle to each of the specified languages. */ void parse_subtitle(const goos::Object& data, GameTextVersion text_ver, GameSubtitleDB& db, const std::string& file_path) { auto font = get_font_bank(text_ver); std::map> banks; for_each_in_list(data.as_pair()->cdr, [&](const goos::Object& obj) { if (obj.is_pair()) { auto& head = car(obj); if (head.is_symbol() && head.as_symbol()->name == "language-id") { if (banks.size() != 0) { throw std::runtime_error("Languages have been set multiple times."); } if (cdr(obj).is_empty_list()) { throw std::runtime_error("At least one language must be set."); } for_each_in_list(cdr(obj), [&](const goos::Object& obj) { auto lang = get_int(obj); if (!db.bank_exists(lang)) { // database has no lang yet banks[lang] = db.add_bank(std::make_shared(lang)); banks[lang]->file_path = file_path; } else { banks[lang] = db.bank_by_id(lang); banks[lang]->file_path = file_path; } }); } else if (head.is_string() || head.is_int()) { if (banks.size() == 0) { throw std::runtime_error("At least one language must be set before defining scenes."); } auto kind = SubtitleSceneKind::Movie; int id = 0; auto entries = cdr(obj); if (head.is_int()) { kind = SubtitleSceneKind::Hint; } else if (car(entries).is_symbol()) { const auto& parm = car(entries).as_symbol()->name; if (parm == ":hint") { entries = cdr(entries); id = car(entries).as_int(); kind = SubtitleSceneKind::HintNamed; } else { throw std::runtime_error("Unknown parameter for subtitle scene"); } entries = cdr(entries); } GameSubtitleSceneInfo scene(kind); if (kind == SubtitleSceneKind::Movie || kind == SubtitleSceneKind::HintNamed) { scene.set_name(head.as_string()->data); } else if (kind == SubtitleSceneKind::Hint) { id = head.as_int(); } scene.set_id(id); scene.m_sorting_group = db.m_subtitle_groups->find_group(scene.name()); scene.m_sorting_group_idx = db.m_subtitle_groups->find_group_index(scene.m_sorting_group); for_each_in_list(entries, [&](const goos::Object& entry) { if (entry.is_pair()) { // expected formats: // (time ) // all arguments have default values. the arguments are: // "speaker" "line" - two strings. one for the speaker's name and one for the actual // line. speaker can be empty. default is just empty string. // :offscreen - speaker is offscreen. default is not offscreen. if (!car(entry).is_int()) { throw std::runtime_error("Each entry must start with a timestamp (number)"); } auto time = car(entry).as_int(); goos::StringObject *speaker = nullptr, *line = nullptr; bool offscreen = false; if (scene.kind() == SubtitleSceneKind::Hint || scene.kind() == SubtitleSceneKind::HintNamed) { offscreen = true; } for_each_in_list(cdr(entry), [&](const goos::Object& arg) { if (arg.is_string()) { if (!speaker) { speaker = arg.as_string(); } else if (!line) { line = arg.as_string(); } else { throw std::runtime_error("Invalid string in subtitle entry"); } } else if (speaker && !line) { throw std::runtime_error( "Invalid object in subtitle entry, expecting actual line string after speaker"); } else if (arg.is_symbol()) { if (scene.kind() == SubtitleSceneKind::Movie && arg.as_symbol()->name == ":offscreen") { offscreen = true; } else { throw std::runtime_error( fmt::format("Unknown parameter {} in subtitle", arg.as_symbol()->name)); } } }); auto line_utf8 = line ? line->data : ""; auto line_str = font->convert_utf8_to_game(line_utf8); auto speaker_utf8 = speaker ? speaker->data : ""; auto speaker_str = font->convert_utf8_to_game(speaker_utf8); scene.add_line(time, line_str, line_utf8, speaker_str, speaker_utf8, offscreen); } else { throw std::runtime_error("Each entry must be a list"); } }); for (auto& [lang, bank] : banks) { if (!bank->scene_exists(scene.name())) { bank->add_scene(scene); } else { auto& old_scene = bank->scene_by_name(scene.name()); old_scene.from_other_scene(scene); } } } else { throw std::runtime_error("Invalid game subtitles file entry: " + head.print()); } } else { throw std::runtime_error("Invalid game subtitles file"); } }); if (banks.size() == 0) { throw std::runtime_error("At least one language must be set."); } } void GameSubtitleGroups::hydrate_from_asset_file() { std::string file_path = (file_util::get_jak_project_dir() / "game" / "assets" / "jak1" / "subtitle" / "subtitle-groups.json") .string(); auto config_str = file_util::read_text_file(file_path); auto group_data = parse_commented_json(config_str, file_path); for (const auto& [key, val] : group_data.items()) { try { if (key == group_order_key) { m_group_order = val.get>(); } else { m_groups[key] = val.get>(); } } catch (std::exception& ex) { fmt::print("Bad subtitle group entry - {} - {}", key, ex.what()); } } } std::string GameSubtitleGroups::find_group(const std::string& scene_name) { for (auto const& [group, scenes] : m_groups) { for (auto const& name : scenes) { if (name == scene_name) { return group; } } } // Add to the uncategorized group if it wasn't found m_groups[uncategorized_group].push_back(scene_name); return uncategorized_group; } int GameSubtitleGroups::find_group_index(const std::string& group_name) { auto it = find(m_group_order.begin(), m_group_order.end(), group_name); if (it != m_group_order.end()) { return it - m_group_order.begin(); } else { return m_group_order.size() - 1; } } void GameSubtitleGroups::remove_scene(const std::string& group_name, const std::string& scene_name) { // TODO - validate group_name m_groups[group_name].erase( std::remove(m_groups[group_name].begin(), m_groups[group_name].end(), scene_name), m_groups[group_name].end()); } void GameSubtitleGroups::add_scene(const std::string& group_name, const std::string& scene_name) { // TODO - validate group_name // TODO - don't add duplicates m_groups[group_name].push_back(scene_name); } GameSubtitleDB load_subtitle_project() { // Load the subtitle files GameSubtitleDB db; db.m_subtitle_groups = std::make_unique(); db.m_subtitle_groups->hydrate_from_asset_file(); goos::Reader reader; std::unordered_map> inputs; std::string subtitle_project = (file_util::get_jak_project_dir() / "game" / "assets" / "game_subtitle.gp").string(); open_text_project("subtitle", subtitle_project, inputs); for (auto& [ver, in] : inputs) { for (auto& filename : in) { auto code = reader.read_from_file({filename}); parse_subtitle(code, ver, db, filename); } } return db; } // TODO - write a deserializer, the compiler still can do the compiling!