Compare commits

...
Sign in to create a new pull request.

22 commits

Author SHA1 Message Date
8b1bb1e3b5 Update README with a basic example 2026-08-10 20:23:14 +00:00
argoneus
d062c94d46 ignoring scripts in side03 2021-12-28 15:52:57 -05:00
argoneus
6f83c7c8a7 added cze as language 2021-12-28 15:52:57 -05:00
argoneus
3319ba7098 excluding Diva files from name parsing 2021-12-28 15:52:57 -05:00
argoneus
b42c731c17 added scene images to scene json 2021-12-28 15:52:57 -05:00
argoneus
421e313f10 removed todo 2021-12-28 15:49:14 -05:00
argoneus
7f33acf8ea fixed diva quest 2021-12-28 15:49:14 -05:00
argoneus
c811af1d08 added --utage switch to diva quest 2021-12-28 15:49:14 -05:00
argoneus
17dc031b9e updated -g to not replace jp translations 2021-12-28 15:49:14 -05:00
argoneus
5a9859cb56 fixed newlines and other windows stuff 2021-12-28 15:49:14 -05:00
argoneus
9f73c50d56 rewrote zsh scripts for asset extract 2021-12-28 15:49:14 -05:00
argoneus
ddca11509b ported update_bgm to python 2021-12-28 15:49:14 -05:00
argoneus
560e00764b ported update_se 2021-12-28 15:49:14 -05:00
argoneus
2da7834c61 updated readme 2021-12-28 15:49:14 -05:00
argoneus
30af5fbe9a updated readme 2021-12-28 15:49:14 -05:00
argoneus
cdf57ce583 fixed quotes thanks j 2021-12-28 15:49:14 -05:00
argoneus
15afb39759 translate command now works with global languages 2021-12-28 15:49:14 -05:00
argoneus
9b998704e2 added basic nametranslations for global languages 2021-12-28 15:49:14 -05:00
argoneus
057273552b fixed up updater 2021-12-28 15:49:14 -05:00
argoneus
77e78a73eb sorting out zsh script calling and folder names 2021-12-28 15:49:14 -05:00
argoneus
d761d12c63 added "is_global" flag 2021-12-28 15:49:14 -05:00
argoneus
70321bcbf2 updated .gitignore and added readme to scripts folder 2021-12-28 15:49:14 -05:00
12 changed files with 632 additions and 211 deletions

3
.gitignore vendored
View file

@ -3,3 +3,6 @@ __pycache__/
*$py.class *$py.class
*.swp *.swp
virtualenv/ virtualenv/
.idea/
tools/*
!tools/.gitkeep

36
README.md Normal file
View file

@ -0,0 +1,36 @@
# divatool
A tool for that one game everyone hates but works on anyway.
## Requirements
Divatool is written in and uses [Python 3](https://www.python.org/downloads/).
There are also several third party dependencies not provided as part of this repository,
mostly used for the `xdudata` command. You need to have the binaries of the following tools
either in your `$PATH` variable or in the [`tools`](./tools) folder. These tools are:
* [clHCA](https://github.com/KinoMyu/FastHCADecoder)
* acb_extract
* usm_extract
* [ffmpeg](https://www.ffmpeg.org/download.html)
* [mono](https://www.mono-project.com/download/stable/) (if using Linux)
Make sure these executables are called the same way on your machine.
## Usage
TODO: add detailed descriptions for all parameters.
Using the tool to update XDUData for the player:
```
divatool.py xdudata update [path to the 'files' folder from the game] [path to the XDUData folder for the player] [path to the 'xdutranslations' repo folder] [path to the temp files folder]
```
Example:
```
divatool.py xdudata update /home/user/gameapp/files /home/user/player/XDUData /home/user/git/xdutranslations /var/tmp
```
Note that the 'files' folder from the app must contain the db files for this to work.

View file

@ -74,7 +74,7 @@ class adx_file(object):
self.encrypted = self.get_val(CRYPT_OFF, CRYPT_LEN) self.encrypted = self.get_val(CRYPT_OFF, CRYPT_LEN)
self.d_offset = self.get_val(DATA_OFF_OFF, DATA_OFF_LEN) self.d_offset = self.get_val(DATA_OFF_OFF, DATA_OFF_LEN)
if self.d_offset < DATA_OFF_MIN: if self.d_offset < DATA_OFF_MIN:
return None return
self.l_exists = 1 self.l_exists = 1
# now load things for math # now load things for math
self.sam_rate = self.get_val(SRATE_OFF, SRATE_LEN) self.sam_rate = self.get_val(SRATE_OFF, SRATE_LEN)
@ -106,13 +106,11 @@ class adx_file(object):
return None return None
if self.l_start > self.sam_count or self.l_end > self.sam_count: if self.l_start > self.sam_count or self.l_end > self.sam_count:
raise ValueError("Invalid Loop Data") raise ValueError("Invalid Loop Data")
return None
if self.l_style not in LOOP_TYPE_SUP: if self.l_style not in LOOP_TYPE_SUP:
raise NotImplementedError( raise NotImplementedError(
"Loop Style {} Not Implemented".format(str(self.l_style)) "Loop Style {} Not Implemented".format(str(self.l_style))
) )
return None ret = dict()
ret = {}
ret["duration"] = self.sam_count / self.sam_rate ret["duration"] = self.sam_count / self.sam_rate
ret["loop_start"] = {} ret["loop_start"] = {}
ret["loop_start"]["seconds"] = self.l_start / self.sam_rate ret["loop_start"]["seconds"] = self.l_start / self.sam_rate
@ -123,7 +121,5 @@ class adx_file(object):
ret["loop_end"] = {} ret["loop_end"] = {}
ret["loop_end"]["seconds"] = self.l_end / self.sam_rate ret["loop_end"]["seconds"] = self.l_end / self.sam_rate
ret["loop_end"]["samples_native"] = self.l_end ret["loop_end"]["samples_native"] = self.l_end
ret["loop_end"]["samples_48k"] = dumb_round( ret["loop_end"]["samples_48k"] = dumb_round(self.l_end / self.sam_rate * 48000)
self.l_end / self.sam_rate * 48000
)
return ret return ret

View file

@ -23,7 +23,7 @@ class BaseDB(object):
return self return self
def __exit__(self, exc_type, exc_value, traceback): def __exit__(self, exc_type, exc_value, traceback):
self.quest_conn.close() self.conn.close()
def attach_dbs(self, databases, path): def attach_dbs(self, databases, path):
for d in databases: for d in databases:

View file

@ -1,16 +1,24 @@
from .base import BaseDB from .base import BaseDB
DATABASES = {
"QuestMst.db": "Quest", def get_dbs_by_lang(language=None):
"QuestSceneMst.db": "QuestScene", databases = {
"QuestPartMst.db": "QuestPart", "QuestMst": "Quest",
"ResourceEntry.db": "ResourceEntry", "QuestSceneMst": "QuestScene",
} "QuestPartMst": "QuestPart",
"ResourceEntry": "ResourceEntry",
}
for key in list(databases):
if not language:
databases[key + ".db"] = databases.pop(key)
else:
databases[key + "_" + language + ".db"] = databases.pop(key)
return databases
class QuestDB(BaseDB): class QuestDB(BaseDB):
def __init__(self, path): def __init__(self, path, language=None):
super().__init__(path, DATABASES) super().__init__(path, get_dbs_by_lang(language))
def get_quests(self): def get_quests(self):
self.cursor.execute( self.cursor.execute(
@ -44,12 +52,14 @@ class QuestDB(BaseDB):
questSceneMstId, questSceneMstId,
name, name,
summaryText, summaryText,
img,
group_concat(partIds) as parts group_concat(partIds) as parts
FROM ( FROM (
SELECT SELECT
qs.questSceneMstId, qs.questSceneMstId,
qs.name, qs.name,
qs.summaryText, qs.summaryText,
qs.img,
CASE CASE
WHEN qp.afterTalkName == '' AND qp.beforeTalkName == '' THEN NULL WHEN qp.afterTalkName == '' AND qp.beforeTalkName == '' THEN NULL
WHEN qp.afterTalkName == '' AND qp.beforeTalkName != '' THEN qp.beforeTalkName WHEN qp.afterTalkName == '' AND qp.beforeTalkName != '' THEN qp.beforeTalkName
@ -66,9 +76,11 @@ class QuestDB(BaseDB):
) )
res = self.cursor.fetchall() res = self.cursor.fetchall()
scenes = {} scenes = {}
for scene_id, name, summary, parts in res: for scene_id, name, summary, img, parts in res:
scenes[str(scene_id)] = {} scenes[str(scene_id)] = {}
scenes[str(scene_id)]["Name"] = name scenes[str(scene_id)]["Name"] = name
scenes[str(scene_id)]["SummaryText"] = summary scenes[str(scene_id)]["SummaryText"] = summary
if img:
scenes[str(scene_id)]["Image"] = img
scenes[str(scene_id)]["Parts"] = parts.split(",") scenes[str(scene_id)]["Parts"] = parts.split(",")
return scenes return scenes

View file

@ -7,16 +7,25 @@ import os
from glob import glob from glob import glob
def update_missions(dir_in, old_path, languages): def update_missions(dir_in, old_path, languages, is_global, utage_in=None):
if not utage_in:
utage_in = os.path.join(dir_in, "Common/Asset/Utage")
if is_global:
languages = ["enm", "zho", "kor"]
for lang in languages:
qdb = db.QuestDB(dir_in, lang)
quest_mst(qdb, old_path, [lang], is_global)
scene_mst(qdb, old_path, [lang], utage_in, is_global)
else:
qdb = db.QuestDB(dir_in) qdb = db.QuestDB(dir_in)
quest_mst(qdb, old_path, languages) quest_mst(qdb, old_path, languages, is_global)
scene_mst(qdb, old_path, dir_in, languages) scene_mst(qdb, old_path, languages, utage_in, is_global)
def scene_mst(qdb, old_path, dir_in, languages): def scene_mst(qdb, old_path, languages, utage_in, is_global):
scenes = qdb.get_scenes() scenes = qdb.get_scenes()
locations = load_locations(dir_in) locations = load_locations(utage_in)
for id, scene in scenes.items(): for id, scene in scenes.items():
i = 0 i = 0
@ -26,14 +35,15 @@ def scene_mst(qdb, old_path, dir_in, languages):
except KeyError as exc: except KeyError as exc:
i += 1 i += 1
if i == len(scene["Parts"]): if i == len(scene["Parts"]):
raise KeyError( if is_global:
f"Could not find folder for scene {id}" break
) from exc raise KeyError(f"Could not find folder for scene {id}") from exc
continue continue
break break
if not is_global:
with io.open( with io.open(
os.path.join(old_path, "XduScene.json"), "w", newline="\n" os.path.join(old_path, "XduScene.json"), "w", encoding="utf-8"
) as json_file: ) as json_file:
json.dump( json.dump(
scenes, json_file, ensure_ascii=False, indent="\t", sort_keys=True scenes, json_file, ensure_ascii=False, indent="\t", sort_keys=True
@ -41,44 +51,42 @@ def scene_mst(qdb, old_path, dir_in, languages):
for lang in languages: for lang in languages:
out_dict = {} out_dict = {}
if lang == "jpn":
for key, value in scenes.items(): for key, value in scenes.items():
name = ""
summary = ""
credit = ""
enabled = False
if lang == "jpn" or is_global:
name = value["Name"]
summary = value["SummaryText"]
credit = "POKELABO"
out_dict[key] = { out_dict[key] = {
"Name": value["Name"], "Name": name,
"SummaryText": value["SummaryText"], "SummaryText": summary,
"Credits": "POKELABO", "Credits": credit,
"Enabled": False, "Enabled": enabled,
}
else:
for key in scenes.keys():
out_dict[key] = {
"Name": "",
"SummaryText": "",
"Credits": "",
"Enabled": False,
} }
langfile = os.path.join(old_path, f"XduSceneNames_{lang}.json") langfile = os.path.join(old_path, f"XduSceneNames_{lang}.json")
if os.path.isfile(langfile): if os.path.isfile(langfile):
with open(langfile, "r") as lang_file: with open(langfile, "r", encoding="utf-8") as lang_file:
lang_dict = json.load(lang_file) lang_dict = json.load(lang_file)
out_dict.update(lang_dict) out_dict.update(lang_dict)
with io.open(langfile, "w", newline="\n") as lang_file: with io.open(langfile, "w", encoding="utf-8") as lang_file:
json.dump( json.dump(
out_dict, out_dict, lang_file, ensure_ascii=False, indent="\t", sort_keys=True,
lang_file,
ensure_ascii=False,
indent="\t",
sort_keys=True,
) )
def quest_mst(qdb, old_path, languages): def quest_mst(qdb, old_path, languages, is_global):
quests = qdb.get_quests() quests = qdb.get_quests()
if not is_global:
with io.open( with io.open(
os.path.join(old_path, "XduQuest.json"), "w", newline="\n" os.path.join(old_path, "XduQuest.json"), "w", encoding="utf-8"
) as json_file: ) as json_file:
json.dump( json.dump(
quests, json_file, ensure_ascii=False, indent="\t", sort_keys=True quests, json_file, ensure_ascii=False, indent="\t", sort_keys=True
@ -86,7 +94,7 @@ def quest_mst(qdb, old_path, languages):
for lang in languages: for lang in languages:
out_dict = {} out_dict = {}
if lang == "jpn": if lang == "jpn" or is_global:
for key, value in quests.items(): for key, value in quests.items():
out_dict[key] = {"Name": value["Name"], "Enabled": False} out_dict[key] = {"Name": value["Name"], "Enabled": False}
else: else:
@ -95,41 +103,40 @@ def quest_mst(qdb, old_path, languages):
langfile = os.path.join(old_path, f"XduQuestNames_{lang}.json") langfile = os.path.join(old_path, f"XduQuestNames_{lang}.json")
if os.path.isfile(langfile): if os.path.isfile(langfile):
with open(langfile, "r") as lang_file: with open(langfile, "r", encoding="utf-8") as lang_file:
lang_dict = json.load(lang_file) lang_dict = json.load(lang_file)
out_dict.update(lang_dict) out_dict.update(lang_dict)
with io.open(langfile, "w", newline="\n") as lang_file: with io.open(langfile, "w", encoding="utf-8") as lang_file:
json.dump( json.dump(
out_dict, out_dict, lang_file, ensure_ascii=False, indent="\t", sort_keys=True,
lang_file,
ensure_ascii=False,
indent="\t",
sort_keys=True,
) )
return quests return quests
def load_locations(dir_in): def load_locations(utage_in):
locations = {} locations = {}
settings = glob( settings = glob("{}/**/Settings/Scenario.tsv".format(utage_in), recursive=True,)
os.path.join(dir_in, "Common/Asset/Utage/**/Settings/Scenario.tsv"),
recursive=True,
)
if len(settings) == 0: if len(settings) == 0:
raise FileNotFoundError("Ya didn't decrypt the files dummy") raise FileNotFoundError("Ya didn't decrypt the files dummy")
for scenario in settings: for scenario in settings:
with open(scenario, "r") as tsv_file: with open(scenario, "r", encoding="utf-8") as tsv_file:
tsv = csv.DictReader(tsv_file, delimiter="\t", quotechar='"') tsv = csv.DictReader(tsv_file, delimiter="\t", quotechar='"')
for row in tsv: for row in tsv:
tokens = row["FileName"].split("/") tokens = row["FileName"].split("/")
if len(tokens) < 3: if len(tokens) < 3:
continue continue
if tokens[2] in locations: folder, _scenario, part_id = tokens
raise ValueError("Conflicting paths for file {tokens[2]}") # side03 seems to only contain prototype scripts so we ignore it
locations[tokens[2]] = tokens[0] if folder == "side03":
continue
if part_id in locations:
raise ValueError(
f"Conflicting paths for scene part {part_id}, existing folder: {locations[part_id]}"
)
locations[part_id] = folder
return locations return locations

View file

@ -9,12 +9,22 @@ import utage
import db import db
import xdudata import xdudata
LANGUAGES = ["jpn", "eng", "rus"] LANGUAGES = ["jpn", "eng", "rus", "cze"]
def main(): def main():
parser = argparse.ArgumentParser(description="Unpack and process XDU data") parser = argparse.ArgumentParser(description="Unpack and process XDU data")
global_flag_parser = argparse.ArgumentParser(add_help=False)
global_flag_parser.add_argument(
"-g",
"--global",
help="Whether we are running on Global XDU.",
action="store_true",
dest="is_global",
default=False,
)
subparsers = parser.add_subparsers( subparsers = parser.add_subparsers(
metavar="<command>", title="subcommands", dest="command" metavar="<command>", title="subcommands", dest="command"
) )
@ -81,7 +91,9 @@ def main():
parser_utage_crypt.add_argument("TARGET", help="Input", type=str) parser_utage_crypt.add_argument("TARGET", help="Input", type=str)
# utage translate # utage translate
parser_utage_translate = parser_utage_subparsers.add_parser( parser_utage_translate = parser_utage_subparsers.add_parser(
"translate", help="Generate keyed tsv and json files" "translate",
help="Generate keyed tsv and json files",
parents=[global_flag_parser],
) )
parser_utage_translate.add_argument("INPUT", help="Input", type=str) parser_utage_translate.add_argument("INPUT", help="Input", type=str)
parser_utage_translate.add_argument( parser_utage_translate.add_argument(
@ -92,7 +104,7 @@ def main():
) )
# utage names # utage names
parser_utage_names = parser_utage_subparsers.add_parser( parser_utage_names = parser_utage_subparsers.add_parser(
"names", help="Generate and update name files" "names", help="Generate and update name files", parents=[global_flag_parser]
) )
parser_utage_names.add_argument("INPUT", help="Input", type=str) parser_utage_names.add_argument("INPUT", help="Input", type=str)
parser_utage_names.add_argument( parser_utage_names.add_argument(
@ -105,13 +117,16 @@ def main():
metavar="<command>", title="subcommand", dest="subcommand" metavar="<command>", title="subcommand", dest="subcommand"
) )
parser_diva_quest = parser_diva_subparsers.add_parser( parser_diva_quest = parser_diva_subparsers.add_parser(
"quest", help="Generate quest JSON files" "quest", help="Generate quest JSON files", parents=[global_flag_parser],
) )
parser_diva_quest.add_argument( parser_diva_quest.add_argument(
"INPUT", "INPUT",
help="Directory of databases containing Quest*.db and ResourceEntry.db", help="Directory of databases containing Quest*.db and ResourceEntry.db",
type=str, type=str,
) )
parser_diva_quest.add_argument(
"--utage", help="Directory of Utage folders and files.", type=str, default=None,
)
parser_diva_quest.add_argument( parser_diva_quest.add_argument(
"OLD", help="Directory with old quest files", nargs="?", type=str, default="." "OLD", help="Directory with old quest files", nargs="?", type=str, default="."
) )
@ -152,23 +167,42 @@ def main():
raise FileNotFoundError("Could not find {}".format(args.TARGET)) raise FileNotFoundError("Could not find {}".format(args.TARGET))
elif args.subcommand == "translate": elif args.subcommand == "translate":
if os.path.isfile(args.INPUT): if os.path.isfile(args.INPUT):
utage.translate.translate_file(args.INPUT, args.TSVDIR, args.JSONDIR) utage.translate.translate_file(
args.INPUT, args.TSVDIR, args.JSONDIR, args.is_global
)
elif os.path.isdir(args.INPUT): elif os.path.isdir(args.INPUT):
utage.translate.translate_dir(args.INPUT, args.TSVDIR, args.JSONDIR) utage.translate.translate_dir(
args.INPUT, args.TSVDIR, args.JSONDIR, args.is_global
)
else: else:
raise FileNotFoundError("Could not find {}".format(args.INPUT)) raise FileNotFoundError("Could not find {}".format(args.INPUT))
elif args.subcommand == "names": elif args.subcommand == "names":
if os.path.isdir(args.INPUT): if os.path.isdir(args.INPUT):
utage.names.update_names(args.INPUT, args.OLD, LANGUAGES) utage.names.update_names(
args.INPUT, args.OLD, LANGUAGES, args.is_global
)
else: else:
raise FileNotFoundError(args.INPUT) raise FileNotFoundError(args.INPUT)
elif args.command == "diva": elif args.command == "diva":
if args.subcommand == "quest": if args.subcommand == "quest":
diva.quest.update_missions(args.INPUT, args.OLD, LANGUAGES) if not args.utage:
diva.quest.update_missions(
args.INPUT, args.OLD, LANGUAGES, args.is_global
)
else:
diva.quest.update_missions(
args.INPUT, args.OLD, LANGUAGES, args.is_global, utage_in=args.utage
)
elif args.command == "xdudata": elif args.command == "xdudata":
if args.subcommand == "update": if args.subcommand == "update":
# add the tools folder to PATH
os.environ["PATH"] += os.pathsep + os.path.join(
os.path.abspath(os.path.dirname(__file__)), "tools"
)
# update the data
xdudata.update_all( xdudata.update_all(
args.CACHE, args.XDUDATA, args.TRANS, args.EXTRACT, LANGUAGES args.CACHE, args.XDUDATA, args.TRANS, args.EXTRACT, LANGUAGES,
) )
return 0 return 0

0
tools/.gitkeep Normal file
View file

View file

@ -9,51 +9,61 @@ from glob import glob
from multiprocessing import Pool from multiprocessing import Pool
def update_names(dir_in, old_path, languages): def update_names(dir_in, old_path, languages, is_global):
names = extract_names(dir_in) names = extract_names(dir_in, is_global)
global_languages = {"enm": 1, "zho": 2, "kor": 3}
if is_global:
languages = global_languages.keys()
# we have to update japanese like the rest because CustomData exists # we have to update japanese like the rest because CustomData exists
# and POKELABO deleted the wedding gear event stuff from the game files # and POKELABO deleted the wedding gear event stuff from the game files
for l in languages: for l in languages:
if l == "jpn": if l == "jpn":
out_dict = dict(zip(names, names)) out_dict = dict(zip(names, names))
elif l in global_languages.keys():
out_dict = {}
for name in names:
spl = name.split("\t")
idx = global_languages[l]
val = spl[idx] if spl[idx] != "None" else ""
out_dict[spl[0]] = val
else: else:
out_dict = dict.fromkeys(names, "") out_dict = dict.fromkeys(names, "")
langfile = os.path.join(old_path, "nametranslations_{}.json".format(l)) langfile = os.path.join(old_path, "nametranslations_{}.json".format(l))
if os.path.isfile(langfile): if os.path.isfile(langfile):
with open(langfile, "r") as lang_file: with open(langfile, "r", encoding="utf-8") as lang_file:
lang_dict = json.load(lang_file) lang_dict = json.load(lang_file)
out_dict.update(lang_dict) out_dict.update(lang_dict)
with io.open( with io.open(
langfile, "w", newline="\n" langfile, "w", encoding="utf-8"
) as lang_file: # you're using git right ) as lang_file: # you're using git right
json.dump( json.dump(
out_dict, out_dict, lang_file, ensure_ascii=False, indent="\t", sort_keys=True,
lang_file,
ensure_ascii=False,
indent="\t",
sort_keys=True,
) )
def extract_names(dir_in): def extract_names(dir_in, is_global):
char_tsv_path = os.path.join(dir_in, "Diva", "Settings", "Character.tsv") char_tsv_path = os.path.join(dir_in, "Diva", "Settings", "Character.tsv")
if not os.path.isfile(char_tsv_path): if not os.path.isfile(char_tsv_path):
raise FileNotFoundError("Character.tsv not found") raise FileNotFoundError("Character.tsv not found")
names, sets = read_char_tsv(char_tsv_path) names, sets = read_char_tsv(char_tsv_path, is_global)
files = [ files = [
f f
for f in glob(os.path.join(dir_in, "**/*.tsv"), recursive=True) for f in glob(os.path.join(dir_in, "**/*.tsv"), recursive=True)
if re.search(r"/[0-9]{9}\.tsv$", f) if re.search(r"[0-9]{9}\.tsv$", f)
and f"{os.path.sep}Diva{os.path.sep}" not in f
] ]
if len(files) == 0: if len(files) == 0:
raise FileNotFoundError("No valid files found in directory: " + dir_in) raise FileNotFoundError("No valid files found in directory: " + dir_in)
# TODO unsupported on global for now
if not is_global:
# tfw gil # tfw gil
# it's still faster on my machine so i'm keeping it # it's still faster on my machine so i'm keeping it
read_partial = partial(read_mission, char_names=names, char_sets=sets) read_partial = partial(read_mission, char_names=names, char_sets=sets)
@ -65,11 +75,11 @@ def extract_names(dir_in):
return names return names
def read_char_tsv(char_tsv_path): def read_char_tsv(char_tsv_path, is_global):
char_names = set() char_names = set()
char_sets = set() char_sets = set()
with open(char_tsv_path, "r") as char_tsv_file: with open(char_tsv_path, "r", encoding="utf-8") as char_tsv_file:
char_tsv = csv.DictReader(char_tsv_file, delimiter="\t", quotechar='"') char_tsv = csv.DictReader(char_tsv_file, delimiter="\t", quotechar='"')
for row in char_tsv: for row in char_tsv:
@ -81,6 +91,11 @@ def read_char_tsv(char_tsv_path):
and row["CharacterName"].strip() and row["CharacterName"].strip()
and row["NameText"].strip() and row["NameText"].strip()
): ):
if is_global:
char_names.add(
f"{row['NameText']}\t{row['CharaEnglish']}\t{row['CharaChinese']}\t{row['CharaKorean']}"
)
else:
char_names.add(row["NameText"]) char_names.add(row["NameText"])
char_sets.add(row["CharacterName"]) char_sets.add(row["CharacterName"])
@ -89,7 +104,7 @@ def read_char_tsv(char_tsv_path):
def read_mission(tsv_path, char_names, char_sets): def read_mission(tsv_path, char_names, char_sets):
new_names = set() new_names = set()
with open(tsv_path, "r") as tsv_file: with open(tsv_path, "r", encoding="utf-8") as tsv_file:
tsv = csv.DictReader(tsv_file, delimiter="\t", quotechar='"') tsv = csv.DictReader(tsv_file, delimiter="\t", quotechar='"')
if ( if (
("Arg1" not in tsv.fieldnames) ("Arg1" not in tsv.fieldnames)
@ -101,8 +116,6 @@ def read_mission(tsv_path, char_names, char_sets):
if row["Command"] and row["Command"].startswith("//"): if row["Command"] and row["Command"].startswith("//"):
continue continue
if row["Text"] and row["Arg1"]: if row["Text"] and row["Arg1"]:
if (row["Arg1"] not in char_names) and ( if (row["Arg1"] not in char_names) and (row["Arg1"] not in char_sets):
row["Arg1"] not in char_sets
):
new_names.add(row["Arg1"]) new_names.add(row["Arg1"])
return new_names return new_names

View file

@ -4,32 +4,34 @@ import io
import json import json
import os import os
import traceback import traceback
from contextlib import suppress
from functools import partial from functools import partial
from glob import glob from glob import glob
from multiprocessing.pool import Pool from multiprocessing.pool import Pool
def translate_dir(dir_in, tsv_out_dir, json_out_dir): def translate_dir(dir_in, tsv_out_dir, json_out_dir, is_global):
files = [ files = [
f f
for f in glob( for f in glob(os.path.join(dir_in, "**/Scenario/*.tsv"), recursive=True)
os.path.join(dir_in, "**/Scenario/*.tsv"), recursive=True if f"{os.path.sep}Diva{os.path.sep}" not in f
)
if "{os.path.sep}Diva{os.path.sep}" not in f
] ]
if len(files) == 0: if len(files) == 0:
raise FileNotFoundError("No valid files found in directory: " + dir_in) raise FileNotFoundError("No valid files found in directory: " + dir_in)
ptrans = partial( ptrans = partial(
translate_file, tsv_out_dir=tsv_out_dir, json_out_dir=json_out_dir translate_file,
tsv_out_dir=tsv_out_dir,
json_out_dir=json_out_dir,
is_global=is_global,
) )
with Pool() as p: with Pool() as p:
p.map(ptrans, files) p.map(ptrans, files)
def translate_file(file_in, tsv_out_dir, json_out_dir): def translate_file(file_in, tsv_out_dir, json_out_dir, is_global):
try: try:
if not file_in.endswith(".tsv"): if not file_in.endswith(".tsv"):
raise ValueError("Invalid File Type for {}".format(file_in)) raise ValueError("Invalid File Type for {}".format(file_in))
@ -48,11 +50,17 @@ def translate_file(file_in, tsv_out_dir, json_out_dir):
tsv_output_path = os.path.join( tsv_output_path = os.path.join(
tsv_out_dir, event_folder, "Scenario", "{}_t.tsv".format(id_num) tsv_out_dir, event_folder, "Scenario", "{}_t.tsv".format(id_num)
) )
json_output_path = os.path.join( languages = ["jpn"]
if is_global:
languages = ["enm", "zho", "kor"]
json_output_paths = {
lang: os.path.join(
json_out_dir, json_out_dir,
event_folder, event_folder,
"{}_translations_jpn.json".format(id_num), "{}_translations_{}.json".format(id_num, lang),
) )
for lang in languages
}
# need to create output paths and avoid races when threading # need to create output paths and avoid races when threading
if not os.path.exists(os.path.dirname(tsv_output_path)): if not os.path.exists(os.path.dirname(tsv_output_path)):
@ -62,35 +70,42 @@ def translate_file(file_in, tsv_out_dir, json_out_dir):
if e.errno != errno.EEXIST: if e.errno != errno.EEXIST:
raise raise
if not os.path.exists(os.path.dirname(json_output_path)): if not os.path.exists(os.path.dirname(json_output_paths[languages[0]])):
try: try:
os.makedirs(os.path.dirname(json_output_path)) os.makedirs(os.path.dirname(json_output_paths[languages[0]]))
except OSError as e: except OSError as e:
if e.errno != errno.EEXIST: if e.errno != errno.EEXIST:
raise raise
with open(file_in, "r") as tsv_file: with open(file_in, "r", encoding="utf-8") as tsv_file:
tsv = csv.DictReader(tsv_file, delimiter="\t", quotechar='"') tsv = csv.DictReader(tsv_file, delimiter="\t", quoting=csv.QUOTE_NONE)
tsv_keyed, json_str = process_tsv(tsv, id_num)
t_fieldnames = tsv.fieldnames
if is_global:
t_fieldnames = tsv.fieldnames[: tsv.fieldnames.index("English") + 1]
tsv_keyed, json_str = process_tsv(tsv, id_num, is_global)
# csv handles newlines, don't set it in io.open # csv handles newlines, don't set it in io.open
with io.open(tsv_output_path, "w", newline="") as tsv_out: with io.open(tsv_output_path, "w", newline="", encoding="utf-8") as tsv_out:
writer = csv.DictWriter( writer = csv.DictWriter(
tsv_out, tsv_out,
delimiter="\t", delimiter="\t",
quotechar='"', quotechar='"',
lineterminator="\n", fieldnames=t_fieldnames,
fieldnames=tsv.fieldnames,
extrasaction="ignore", extrasaction="ignore",
) )
writer.writeheader() writer.writeheader()
for row in tsv_keyed: for row in tsv_keyed:
writer.writerow(row) writer.writerow(row)
with io.open(json_output_path, "w", newline="\n") as json_out: for lang in languages:
if not json_str[lang]:
continue
with io.open(json_output_paths[lang], "w", encoding="utf-8") as json_out:
# we don't want to sort these because they're _1, ..., _10, etc # we don't want to sort these because they're _1, ..., _10, etc
json.dump( json.dump(
json_str, json_str[lang],
json_out, json_out,
ensure_ascii=False, ensure_ascii=False,
indent="\t", indent="\t",
@ -102,10 +117,14 @@ def translate_file(file_in, tsv_out_dir, json_out_dir):
print("Error processing file: " + file_in) print("Error processing file: " + file_in)
def process_tsv(tsv, id_num): def process_tsv(tsv, id_num, is_global):
i = 0 i = 0
tsv_keyed = [] tsv_keyed = []
key_dict = {} key_dict = {"jpn": {}}
if is_global:
key_dict["enm"] = {}
key_dict["zho"] = {}
key_dict["kor"] = {}
for row in tsv: for row in tsv:
try: try:
@ -115,8 +134,14 @@ def process_tsv(tsv, id_num):
if row["Text"] and row["Text"].strip(): if row["Text"] and row["Text"].strip():
key = "{}_{}".format(id_num, i) key = "{}_{}".format(id_num, i)
i += 1 i += 1
key_dict["jpn"][key] = row["Text"]
if is_global:
with suppress(KeyError):
if row["English"]:
key_dict["enm"][key] = row["English"]
key_dict["zho"][key] = row["Chinese"]
key_dict["kor"][key] = row["Korean"]
row["English"] = key row["English"] = key
key_dict[key] = row["Text"]
tsv_keyed.append(row) tsv_keyed.append(row)
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()

303
xdudata/asset_extract.py Normal file
View file

@ -0,0 +1,303 @@
import functools
import multiprocessing
import os
import shutil
import subprocess
from contextlib import contextmanager
from distutils.dir_util import copy_tree
from glob import glob
from adx import extract_loop_data_from_dir
OPERATING_SYSTEM = os.name
CHUNK_SIZE = 100
def process_se(cache_dir, extract_dir, update_dir):
copy_tree(
os.path.join(cache_dir, "Android/Asset/Sound/Se"),
os.path.join(extract_dir, "Se"),
)
from_dir = "Se"
to_dir = "Se_Extracted"
acb_prefix = "_vgmt_acb_ext"
pool = get_pool()
with change_cwd(extract_dir):
os.makedirs(to_dir, exist_ok=True)
to_del = glob("{}/**/*.wav".format(from_dir), recursive=True)
to_del.extend(glob("{}/**/*.hca".format(from_dir), recursive=True))
for file in to_del:
os.remove(file)
acb_files = [file for file in glob("{}/*.acb".format(from_dir))]
acb_cmd = get_dotnet_cmd("acb_extract.exe")
run_program(acb_files, cmd=acb_cmd)
hca_files = [
file for file in glob("{}/**/*.hca".format(from_dir), recursive=True)
]
for hca_sublist in split_list(hca_files, CHUNK_SIZE):
run_program(hca_sublist, cmd="clHCA")
basenames = [os.path.splitext(os.path.basename(file))[0] for file in acb_files]
char_secs = [basename.split("_", maxsplit=1) for basename in basenames]
for cs in char_secs:
char = cs[0]
sec = str(cs[1]) if len(cs) == 2 else ""
os.makedirs(os.path.join(to_dir, char, sec), exist_ok=True)
wav_files = glob(
"{}/{}_{}/**/*.wav".format(from_dir, acb_prefix, "_".join(cs)),
recursive=True,
)
partial_ffmpeg_cmd = functools.partial(run_program, cmd="ffmpeg")
wav_cmds = [
[
"-i",
os.path.abspath(file),
"-acodec",
"libopus",
"-ab",
"192k",
"-af",
"aresample=48000",
os.path.abspath(
"{}/{}/{}/{}.opus".format(
to_dir,
char,
sec,
os.path.splitext(os.path.basename(file))[0],
)
),
"-n",
]
for file in wav_files
]
pool.map(partial_ffmpeg_cmd, wav_cmds)
copy_tree(os.path.join(extract_dir, to_dir), os.path.join(update_dir, "Se"))
def process_bgm(cache_dir, extract_dir, update_dir):
copy_tree(
os.path.join(cache_dir, "Android/Asset/Sound/Bgm"),
os.path.join(extract_dir, "Bgm"),
)
from_dir = "Bgm"
to_dir = "Bgm_Extracted"
loop_file = "loop.json"
pool = get_pool()
with change_cwd(extract_dir):
os.makedirs(to_dir, exist_ok=True)
to_del = glob("{}/**/*.adx".format(from_dir), recursive=True)
for file in to_del:
os.remove(file)
acb_files = [file for file in glob("{}/*.acb".format(from_dir))]
acb_cmd = get_dotnet_cmd("acb_extract.exe")
run_program(acb_files, cmd=acb_cmd)
adx_files = [
file for file in glob("{}/**/*.adx".format(from_dir), recursive=True)
]
partial_ffmpeg_cmd = functools.partial(run_program, cmd="ffmpeg")
wav_cmds = [
[
"-i",
os.path.abspath(file),
"-acodec",
"libopus",
"-ab",
"192k",
"-af",
"aresample=48000",
os.path.abspath(
"{}/{}.opus".format(
to_dir, os.path.splitext(os.path.basename(file))[0],
)
),
"-n",
]
for file in adx_files
]
pool.map(partial_ffmpeg_cmd, wav_cmds)
extract_loop_data_from_dir(from_dir, loop_file)
shutil.copy2(loop_file, os.path.join(to_dir, "BgmLoop.json"))
copy_tree(os.path.join(extract_dir, to_dir), os.path.join(update_dir, "Bgm"))
def process_voice(cache_dir, extract_dir, update_dir):
copy_tree(
os.path.join(cache_dir, "Android/Asset/Sound/Voice"),
os.path.join(extract_dir, "Voice"),
)
from_dir = "Voice"
to_dir = "Voice_Extracted"
acb_prefix = "_vgmt_acb_ext"
pool = get_pool()
with change_cwd(extract_dir):
os.makedirs(to_dir, exist_ok=True)
to_del = glob("{}/**/*.wav".format(from_dir), recursive=True)
to_del.extend(glob("{}/**/*.hca".format(from_dir), recursive=True))
for file in to_del:
os.remove(file)
acb_files = [file for file in glob("{}/*.acb".format(from_dir))]
acb_cmd = get_dotnet_cmd("acb_extract.exe")
run_program(acb_files, cmd=acb_cmd)
hca_files = [
file for file in glob("{}/**/*.hca".format(from_dir), recursive=True)
]
for hca_sublist in split_list(hca_files, CHUNK_SIZE):
run_program(hca_sublist, cmd="clHCA")
basenames = [os.path.splitext(os.path.basename(file))[0] for file in acb_files]
char_secs = [basename.split("_", maxsplit=1) for basename in basenames]
for cs in char_secs:
char = cs[0]
sec = str(cs[1]) if len(cs) == 2 else ""
os.makedirs(os.path.join(to_dir, char, sec), exist_ok=True)
wav_files = glob(
"{}/{}_{}/**/*.wav".format(from_dir, acb_prefix, "_".join(cs)),
recursive=True,
)
partial_ffmpeg_cmd = functools.partial(run_program, cmd="ffmpeg")
wav_cmds = [
[
"-i",
os.path.abspath(file),
"-acodec",
"libopus",
"-ab",
"192k",
"-af",
"aresample=48000",
os.path.abspath(
"{}/{}/{}/{}.opus".format(
to_dir,
char,
sec,
os.path.splitext(os.path.basename(file))[0],
)
),
"-n",
]
for file in wav_files
]
pool.map(partial_ffmpeg_cmd, wav_cmds)
copy_tree(os.path.join(extract_dir, to_dir), os.path.join(update_dir, "Voice"))
def process_movie(cache_dir, extract_dir, update_dir):
copy_tree(
os.path.join(cache_dir, "Common/Asset/Movie"),
os.path.join(extract_dir, "Movie"),
)
from_dir = "Movie"
to_dir = "Movie_Extracted"
pool = get_pool()
with change_cwd(extract_dir):
os.makedirs(to_dir, exist_ok=True)
to_del = glob("{}/**/*.adx".format(from_dir), recursive=True)
to_del.extend(glob("{}/**/*.m2v".format(from_dir), recursive=True))
for file in to_del:
os.remove(file)
movie_paths = [
os.path.abspath(os.path.splitext(file)[0])
for file in glob("{}/*.usm".format(from_dir))
if not os.path.basename(file).startswith("mini_radio")
]
usm_files = [[path + ".usm"] for path in movie_paths]
usm_cmd = get_dotnet_cmd("usm_extract.exe")
partial_usm_cmd = functools.partial(run_program, cmd=usm_cmd)
pool.map(partial_usm_cmd, usm_files)
partial_ffmpeg_cmd = functools.partial(run_program, cmd="ffmpeg")
mux_cmds = [
[
"-i",
path + ".adx",
"-i",
path + ".m2v",
"-c:a",
"libopus",
"-b:a",
"196k",
"-af",
"aresample=48000",
"-c:v",
"libvpx",
"-b:v",
"1M",
"-threads",
"1",
os.path.abspath("{}/{}.webm".format(to_dir, os.path.basename(path))),
"-n",
]
for path in movie_paths
]
pool.map(partial_ffmpeg_cmd, mux_cmds)
copy_tree(
os.path.join(extract_dir, to_dir), os.path.join(update_dir, "Asset", "Movie")
)
@contextmanager
def change_cwd(new_path):
old_cwd = os.getcwd()
os.chdir(new_path)
try:
yield
finally:
os.chdir(old_cwd)
def get_dotnet_cmd(name):
ext = os.path.splitext(name)[1]
if ext != ".exe":
raise RuntimeError("This doesn't seem like a .NET program.")
path = shutil.which(name)
if OPERATING_SYSTEM == "nt":
return [path]
return ["mono", path]
def run_program(args, cmd=None):
if isinstance(cmd, str):
cmd = [cmd]
full_cmd = cmd + args
subprocess.run(full_cmd)
def get_pool(size=multiprocessing.cpu_count()):
pool = multiprocessing.Pool(size)
return pool
def split_list(input_list, chunk_size):
return [
input_list[i : i + chunk_size] for i in range(0, len(input_list), chunk_size)
]

View file

@ -1,90 +1,53 @@
from contextlib import suppress
import diva import diva
import shutil
import os import os
import subprocess
import tempfile import tempfile
import utage import utage
from distutils.dir_util import copy_tree, remove_tree from distutils.dir_util import copy_tree, remove_tree
from glob import glob from glob import glob
from xdudata.asset_extract import process_se, process_bgm, process_voice, process_movie
def update_all(
cache_dir, update_dir, translation_dir, extract_dir, languages,
):
print("Cleaning cache")
cleanup_cache(cache_dir)
def update_all(cache_dir, update_dir, translation_dir, extract_dir, languages):
print("Copying images") print("Copying images")
copy_images(cache_dir, update_dir) copy_images(cache_dir, update_dir)
print("Processing TSV files") print("Processing TSV files")
update_tsv(cache_dir, update_dir, translation_dir, languages) update_tsv(cache_dir, update_dir, extract_dir, translation_dir, languages)
print("Processing Quest Json files") print("Processing Quest Json files")
diva.quest.update_missions(cache_dir, translation_dir, languages) diva.quest.update_missions(extract_dir, translation_dir, languages, False)
# this code isn't going to run on windows because i need to rewrite it
# it just calls my zsh scripts and generates it that way, need to replace them
# with real python or something
print("Processing Sound Effects") print("Processing Sound Effects")
copy_tree( process_se(cache_dir, extract_dir, update_dir)
os.path.join(cache_dir, "Android/Asset/Sound/Se"),
os.path.join(extract_dir, "Se"),
)
subprocess.run("./update_se.zsh", shell=True, cwd=extract_dir)
copy_tree(os.path.join(extract_dir, "se"), os.path.join(update_dir, "Se"))
print("Processing BGM - don't forget to copy that loop file") print("Processing BGM")
copy_tree( process_bgm(cache_dir, extract_dir, update_dir)
os.path.join(cache_dir, "Android/Asset/Sound/Bgm"),
os.path.join(extract_dir, "Bgm"),
)
subprocess.run("./update_bgm.zsh", shell=True, cwd=extract_dir)
copy_tree(
os.path.join(extract_dir, "bgm"), os.path.join(update_dir, "Bgm")
)
print("Processing voices") print("Processing voices")
copy_tree( process_voice(cache_dir, extract_dir, update_dir)
os.path.join(cache_dir, "Android/Asset/Sound/Voice"),
os.path.join(extract_dir, "Voice"),
)
subprocess.run("./update_voice.zsh", shell=True, cwd=extract_dir)
copy_tree(
os.path.join(extract_dir, "voice"), os.path.join(update_dir, "Voice")
)
print("Processing movies - this might take forever") print("Processing movies - this might take forever")
copy_tree( process_movie(cache_dir, extract_dir, update_dir)
os.path.join(cache_dir, "Common/Asset/Movie"),
os.path.join(extract_dir, "Movie"),
)
subprocess.run("./update_movie.zsh", shell=True, cwd=extract_dir)
copy_tree(
os.path.join(extract_dir, "movie"),
os.path.join(update_dir, "Asset/Movie"),
)
def update_tsv(cache_dir, update_dir, translation_dir, languages): def cleanup_cache(cache_dir):
utage_tmp = tempfile.mkdtemp() with suppress(FileNotFoundError):
copy_tree(os.path.join(cache_dir, "Common/Asset/Utage"), utage_tmp) shutil.rmtree(os.path.join(cache_dir, "Common/Asset/Utage/event32"))
utage.crypt.crypt_dir( os.remove(
utage_tmp, bytearray("SampleSecretKey", "utf-8"), False, False os.path.join(
cache_dir, "Common/Asset/Utage/side01/Scenario/Sheet2.tsv.utage"
) )
for encrypted in glob(
os.path.join(utage_tmp, "**/*.utage"), recursive=True
):
try:
os.remove(encrypted)
except:
pass
utage.translate.translate_dir(
utage_tmp,
os.path.join(update_dir, "Utage"),
os.path.join(translation_dir, "Missions"),
) )
utage.names.update_names(utage_tmp, translation_dir, languages)
copy_tree(
os.path.join(utage_tmp, "Diva"), os.path.join(update_dir, "Utage/Diva")
)
remove_tree(utage_tmp)
def copy_images(cache_dir, update_dir): def copy_images(cache_dir, update_dir):
@ -93,6 +56,35 @@ def copy_images(cache_dir, update_dir):
os.path.join(update_dir, "Asset/Image"), os.path.join(update_dir, "Asset/Image"),
) )
copy_tree( copy_tree(
os.path.join(cache_dir, "Common/Sample"), os.path.join(cache_dir, "Common/Sample"), os.path.join(update_dir, "Sample"),
os.path.join(update_dir, "Sample"),
) )
def update_tsv(cache_dir, update_dir, extract_dir, translation_dir, languages):
utage_tmp = tempfile.mkdtemp()
copy_tree(os.path.join(cache_dir, "Common/Asset/Utage"), utage_tmp)
utage.crypt.crypt_dir(
utage_tmp, bytearray("SampleSecretKey", "utf-8"), False, False
)
for encrypted in glob(os.path.join(utage_tmp, "**/*.utage"), recursive=True):
try:
os.remove(encrypted)
except:
pass
# copy over files for the quest updater
copy_tree(utage_tmp, os.path.join(extract_dir, "Common/Asset/Utage"))
shutil.copy2(os.path.join(cache_dir, "QuestMst.db"), extract_dir)
shutil.copy2(os.path.join(cache_dir, "QuestSceneMst.db"), extract_dir)
shutil.copy2(os.path.join(cache_dir, "QuestPartMst.db"), extract_dir)
shutil.copy2(os.path.join(cache_dir, "ResourceEntry.db"), extract_dir)
utage.translate.translate_dir(
utage_tmp,
os.path.join(update_dir, "Utage"),
os.path.join(translation_dir, "Missions"),
False,
)
utage.names.update_names(utage_tmp, translation_dir, languages, False)
copy_tree(os.path.join(utage_tmp, "Diva"), os.path.join(update_dir, "Utage/Diva"))
remove_tree(utage_tmp)