#include "subtitles_ser.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" 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("car: invalid pair"); } return x.as_pair()->car; } const goos::Object& cdr(const goos::Object& x) { if (!x.is_pair()) { throw std::runtime_error("cdr: 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, GameTextDB& db) { const GameTextFontBank* font = nullptr; 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_symbol("text-version")) { if (font) { throw std::runtime_error("text version is already set"); } const auto& ver_name = car(cdr(obj)); if (!ver_name.is_symbol()) { throw std::runtime_error("invalid text version entry"); } font = get_font_bank(ver_name.as_symbol()->name); } else if (head.is_int()) { if (!font) { throw std::runtime_error("Text version must be set before defining entries."); } 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, GameSubtitleDB& db, const std::string& file_path) { const GameTextFontBank* font = nullptr; 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_symbol("text-version")) { if (font) { throw std::runtime_error("text version is already set"); } const auto& ver_name = car(cdr(obj)); if (!ver_name.is_symbol()) { throw std::runtime_error("invalid text version entry"); } font = get_font_bank(ver_name.as_symbol()->name); } else if (head.is_string() || head.is_int()) { if (!font) { throw std::runtime_error("Text version must be set before defining entries."); } 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 (entries.is_pair() && 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_str = font->convert_utf8_to_game(line ? line->data : ""); auto speaker_str = font->convert_utf8_to_game(speaker ? speaker->data : ""); scene.add_line(time, line_str, speaker_str, offscreen); } else { throw std::runtime_error( fmt::format("{} | Each entry must be a non-empty list", scene.name())); } }); 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."); } } GameTextVersion parse_text_only_version(const std::string& filename) { goos::Reader reader; return parse_text_only_version(reader.read_from_file({filename})); } GameTextVersion parse_text_only_version(const goos::Object& data) { const GameTextFontBank* font = nullptr; for_each_in_list(data.as_pair()->cdr, [&](const goos::Object& obj) { if (obj.is_pair()) { auto& head = car(obj); if (head.is_symbol("text-version")) { if (font) { throw std::runtime_error("text version is already set"); } const auto& ver_name = car(cdr(obj)); if (!ver_name.is_symbol()) { throw std::runtime_error("invalid text version entry"); } font = get_font_bank(ver_name.as_symbol()->name); } } }); if (!font) { throw std::runtime_error("text version not found"); } return font->version(); } 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) { lg::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) { if (m_groups.count(group_name) == 0) { lg::error("Subtitle group {} doesn't exist! Abort.", group_name); return; } 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) { std::string group = group_name; if (m_groups.count(group_name) == 0) { lg::error("Subtitle group {} doesn't exist! Add to uncategorized.", group_name); group = uncategorized_group; } auto it = std::find(m_groups[group].begin(), m_groups[group].end(), scene_name); if (it != m_groups[group].end()) { lg::error("Scene {} already exists in group {}", scene_name, group); } else { m_groups[group].push_back(scene_name); } } // 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::vector& 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() && o.as_pair()->cdr.is_pair()) { auto& action = o.as_pair()->car.as_symbol()->name; if (action == "file") { auto& in = o.as_pair()->cdr.as_pair()->car.as_string()->data; inputs.push_back(in); } else { throw std::runtime_error(fmt::format("unknown action {} in {} project", action, kind)); } } else { throw std::runtime_error(fmt::format("invalid entry in {} project", kind)); } }); } 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(); try { goos::Reader reader; std::vector 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& filename : inputs) { auto code = reader.read_from_file({filename}); parse_subtitle(code, db, filename); } } catch (std::runtime_error& e) { lg::error("error loading subtitle project: {}", e.what()); } return db; } // TODO - write a deserializer, the compiler still can do the compiling!