# -*- coding: utf-8 -*-
#
# Copyright (C) 2020 Peter Rowlands <peter@pmrowla.com>
# Copyright (C) 2014 tinfoil <https://bitbucket.org/tinfoil/>
#
# This file is a part of pylivemaker.
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
"""LiveMaker LSB script CLI tool."""
import hashlib
import re
import shutil
import sys
import csv
from pathlib import Path
import click
import numpy
from lxml import etree
from livemaker.lsb import LMScript
from livemaker.lsb.command import BaseComponentCommand, Calc, CommandType, Jump, LabelReference
from livemaker.lsb.core import OpeData, OpeDataType, OpeFuncType, Param, ParamType
from livemaker.lsb.menu import LPMSelectionChoice
from livemaker.lsb.novel import LNSDecompiler, LNSCompiler, TWdChar, TWdOpeReturn
from livemaker.project import PylmProject
from livemaker.lsb.translate import make_identifier, TextBlockIdentifier, TextMenuIdentifier
from livemaker.exceptions import BadLsbError, BadTextIdentifierError, LiveMakerException
from .cli import _version, __version__
@click.group()
@click.version_option(version=__version__, message=_version)
def lmlsb():
"""Command-line tool for manipulating LSB scripts."""
pass
@lmlsb.command()
@click.argument("input_file", metavar="file", required=True, type=click.Path(exists=True, dir_okay=False))
def probe(input_file):
"""Output information about the specified LSB file in human-readable form.
Novel script scenario character and line count are estimates. Depending
on how a script was originally created, actual char/line counts may vary.
"""
print(input_file)
with open(input_file, "rb") as f:
try:
lm = LMScript.from_file(f)
except BadLsbError as e:
sys.exit("Could not read file: {}".format(e))
if lm._parsed_from == "lsc":
print("LiveMaker LSC script file:")
elif lm._parsed_from == "lsc-xml":
print("LiveMaker XML LSC script file:")
elif lm._parsed_from == "lsb":
print("LiveMaker compiled LSB script file:")
else:
print("LiveMaker script file:")
print(" Version: {} (LiveMaker{})".format(lm.version, lm.lm_version))
print(" Total commands: {}".format(len(lm)))
cmd_types = set()
for cmd in lm.commands:
cmd_types.add(cmd.type)
print(" Command types: {}".format(", ".join([x.name for x in sorted(cmd_types)])))
scenarios = lm.text_scenarios()
print(" Total text scenarios: {}".format(len(scenarios)))
for index, name, scenario in scenarios:
if not name:
name = "Unlabeled scenario"
print(" {}".format(name))
tpwd_types = set()
char_count = 0
line_count = 0
for wd in scenario.body:
tpwd_types.add(wd.type)
if isinstance(wd, TWdChar):
char_count += 1
elif isinstance(wd, TWdOpeReturn):
line_count += 1
print(" LiveNovel scenario version: {}".format(scenario.version))
print(" TpWd types: {}".format(", ".join([x.name for x in sorted(tpwd_types)])))
print(" Approx. character count: {}".format(char_count))
if char_count:
# don't count line breaks in event-only scenarios
print(" Approx. line count: {}".format(line_count))
@lmlsb.command()
@click.argument("input_file", metavar="file", required=True, nargs=-1, type=click.Path(exists=True))
def validate(input_file):
"""Verify that the specified LSB file(s) can be processed.
Validation is done by disassembling an input file, reassembling it,
and then comparing the SHA256 digests of the original and reassembled
versions of the file.
If a file contains text scenarios, a test will also be done to verify that
the scenarios can be decompiled, recompiled, and then reinserted into the
lsb file.
"""
for path in input_file:
print(path)
with open(path, "rb") as f:
data = f.read()
try:
lsb = LMScript.from_lsb(data)
orig = hashlib.sha256(data).hexdigest()
except BadLsbError as e:
print(" Failed to parse file: {}".format(e))
continue
try:
built_data = lsb.to_lsb()
reassembled = hashlib.sha256(built_data).hexdigest()
except BadLsbError as e:
print(" Failed to reassemble file: {}".format(e))
continue
print(" Orig: {} ({} bytes)".format(orig, len(data)))
print(" New: {} ({} bytes)".format(reassembled, len(built_data)))
if orig == reassembled:
print(" SHA256 digest validation passed")
if orig != reassembled:
print(" SHA256 digest validation failed")
for line, name, scenario in lsb.text_scenarios(run_order=False):
print(" {}".format(name))
orig_bytes = scenario._struct().build(scenario)
dec = LNSDecompiler()
script = dec.decompile(scenario)
cc = LNSCompiler()
new_body = cc.compile(script)
scenario.replace_body(new_body, ruby_text=cc.ruby_text)
new_bytes = scenario._struct().build(scenario)
if new_bytes == orig_bytes:
print(" script passed")
else:
print(" script mismatch, {} {}".format(len(orig_bytes), len(new_bytes)))
@lmlsb.command()
@click.option(
"-m", "--mode", type=click.Choice(["text", "xml", "lines"]), default="text", help="Output mode (defaults to text)"
)
@click.option(
"-e",
"--encoding",
type=click.Choice(["cp932", "utf-8"]),
default="utf-8",
help="Output text encoding (defaults to utf-8).",
)
@click.option(
"-o",
"--output-file",
type=click.Path(dir_okay=False),
help="Output file. If unspecified, output will be dumped to stdout.",
)
@click.argument("input_file", required=True, nargs=-1, type=click.Path(exists=True, dir_okay="False"))
def dump(mode, encoding, output_file, input_file):
"""Dump the contents of the specified LSB file(s) to stdout in a human-readable format.
For text mode, the full LSB will be output as human-readable text.
For xml mode, the full LSB file will be output as an XML document.
For lines mode, only text lines will be output.
"""
if output_file:
outf = open(output_file, mode="w", encoding=encoding)
else:
outf = sys.stdout
pylm = None
skip_pylm = False
for path in input_file:
try:
if skip_pylm:
call_name = None
elif not pylm:
try:
pylm = PylmProject(path)
call_name = pylm.call_name(path)
except LiveMakerException:
call_name = None
skip_pylm = True
with open(path, "rb") as f:
lsb = LMScript.from_file(f, call_name=call_name, pylm=pylm)
if pylm:
pylm.update_labels(lsb)
except BadLsbError as e:
sys.stderr.write(" Failed to parse file: {}".format(e))
continue
if mode == "xml":
root = lsb.to_xml()
print(
etree.tostring(root, encoding=encoding, pretty_print=True, xml_declaration=True).decode(encoding),
file=outf,
)
elif mode == "lines":
lsb_path = Path(path)
for line, name, scenario in lsb.text_scenarios():
if name:
name = "{}-{}.lns".format(lsb_path.stem, name)
if not name:
name = "{}-line{}.lns".format(lsb_path.stem, line)
print(name, file=outf)
print("------", file=outf)
for block in scenario.get_text_blocks():
for line in block.text.splitlines():
print(line, file=outf)
else:
for c in lsb.commands:
if c.Mute:
mute = ";"
else:
mute = ""
s = ["{}{:4}: {}".format(mute, c.LineNo, " " * c.Indent)]
s.append(str(c).replace("\r", "\\r").replace("\n", "\\n"))
ref = c.get("Page")
if ref and isinstance(ref, LabelReference):
if ref.Page.endswith("lsb") and pylm:
# resolve lsb refs
line_no, name = pylm.resolve_label(ref)
if line_no is not None:
s.append(f" (Label {line_no}: {name})")
print("".join(s), file=outf)
if c.type == CommandType.TextIns:
dec = LNSDecompiler()
print(dec.decompile(c.get("Text")), file=outf)
def _escape_scenario_name(name):
"""Replace invalid Windows path characters with underscore."""
return re.sub(r'[\/:*?"<>|]+', "_", name)
@lmlsb.command()
@click.option(
"-e",
"--encoding",
type=click.Choice(["cp932", "utf-8"]),
default="utf-8",
help="Output text encoding (defaults to utf-8).",
)
@click.option(
"-o",
"--output-dir",
type=click.Path(file_okay=False),
help="Output directory. Defaults to the current working directory if not specified."
" If directory does not exist it will be created.",
)
@click.argument("input_file", required=True, nargs=-1, type=click.Path(exists=True, dir_okay=False))
def extract(encoding, output_dir, input_file):
"""Extract decompiled LiveNovel scripts from the specified input file(s).
By default, extracted scripts will be encoded as utf-8, but if you intend
to patch a script back into an LSB, you will still be limited to cp932
characters only.
Output files will be named <LSB name>-<scenario name>.lns
"""
if output_dir:
output_dir = Path(output_dir)
if not output_dir.exists():
output_dir.mkdir(parents=True)
else:
output_dir = Path.cwd()
for path in input_file:
print("Extracting scripts from {}".format(path))
lsb_path = Path(path)
lsb = LMScript.from_file(path)
lsb_ref_filename = "{}.lsbref".format(lsb_path.stem)
with open(output_dir.joinpath(lsb_ref_filename), "w", encoding=encoding) as lsb_ref_file:
for line, name, scenario in lsb.text_scenarios():
if name:
name = "{}-{}.lns".format(lsb_path.stem, _escape_scenario_name(name))
if not name:
name = "{}-line{}.lns".format(lsb_path.stem, line)
output_path = output_dir.joinpath(name)
dec = LNSDecompiler()
with open(output_path, "w", encoding=encoding) as f:
f.write(dec.decompile(scenario))
print(" wrote {}".format(output_path))
lsb_ref_file.write("{}:{}\n".format(name, line))
@lmlsb.command()
@click.option(
"-e",
"--encoding",
type=click.Choice(["cp932", "utf-8"]),
default="utf-8",
help="The text encoding of script_file (defaults to utf-8).",
)
@click.argument("lsb_file", required=True, type=click.Path(exists=True, dir_okay=False))
@click.argument("script_file", required=True, type=click.Path(exists=True, dir_okay=False))
# TODO: make this optional and parse label/line number from script filename
@click.argument("line_number", required=True, type=int)
@click.option("--no-backup", is_flag=True, default=False, help="Do not generate backup of original archive file(s).")
def insert(encoding, lsb_file, script_file, line_number, no_backup):
"""Compile specified LNS script and insert it into the specified LSB file.
The LSB command at line_number must be a TextIns command. The existing text
block of the specified TextIns command will be replaced with the new one from
script_file.
script_file should be an LNS script which was initially generated by lmlsb extract.
The original LSB file will be backed up to <lsb_file>.bak unless the
--no-backup option is specified.
"""
insert_lns(encoding, lsb_file, script_file, line_number, no_backup)
@lmlsb.command()
@click.option(
"-e",
"--encoding",
type=click.Choice(["cp932", "utf-8"]),
default="utf-8",
help="The text encoding of script_file (defaults to utf-8).",
)
@click.argument("lsb_file", required=True, type=click.Path(exists=True, dir_okay=False))
@click.argument("script_dir", type=click.Path(file_okay=False))
@click.option("--no-backup", is_flag=True, default=False, help="Do not generate backup of original archive file(s).")
@click.option(
"--ignore-missing", is_flag=True, default=False, help="Continue insert in case of missing script file(s)."
)
def batchinsert(encoding, lsb_file, script_dir, no_backup, ignore_missing):
"""Compile specified LNS script directory and insert it into the specified LSB file according to
the Reference file.
The Reference file must be inside script_dir.
script_dir should be an LNS script directory which was initially generated by lmlsb extract.
The original LSB file will be backed up to <lsb_file>.bak unless the
--no-backup option is specified.
If a file from .lsbref is missing, insertation is stopped, unless the
--ignore-missing option is specified
"""
script_dir = Path(script_dir)
if not script_dir.exists():
print("Input directory does not exist")
return
if not no_backup:
print("Backing up original LSB.")
shutil.copyfile(str(lsb_file), "{}.bak".format(str(lsb_file)))
with open(lsb_file, "rb") as f:
try:
lsb = LMScript.from_file(f)
except LiveMakerException as e:
sys.exit("Could not open LSB file: {}".format(e))
lsb_path = Path(lsb_file)
lsb_ref_filename = "{}.lsbref".format(lsb_path.stem)
with open(script_dir.joinpath(lsb_ref_filename), "r", encoding=encoding) as lsb_ref_file:
while True:
ln = lsb_ref_file.readline()
if ln == "":
break
lnsplt = ln.split(":")
script_file = script_dir.joinpath(lnsplt[0])
line_number = int(lnsplt[1])
if not Path(script_file).exists():
if ignore_missing:
print("Warning: script file {} is missing, skipped.".format(script_file))
continue
else:
sys.exit("Script file is missing: {}".format(script_file))
with open(script_file, "rb") as f:
script = f.read().decode(encoding)
try:
cc = LNSCompiler()
new_body = cc.compile(script)
except LiveMakerException as e:
sys.exit("Could not compile script file: {}".format(e))
for index, name, scenario in lsb.text_scenarios():
if index == line_number:
print("Scenario {} at line {} will be replaced.".format(name, index))
scenario.replace_body(new_body, ruby_text=cc.ruby_text)
break
try:
new_lsb_data = lsb.to_lsb()
with open(lsb_file, "wb") as f:
f.write(new_lsb_data)
print("Wrote new LSB.")
except LiveMakerException as e:
sys.exit("Could not generate new LSB file: {}".format(e))
# Known property data types
EDITABLE_PROPERTY_TYPES = {
# PR_NONE = 0x00
# PR_NAME = 0x01
# PR_PARENT = 0x02
# PR_SOURCE = 0x03
# PR_LEFT = 0x04
# PR_TOP = 0x05
# PR_WIDTH = 0x06
# PR_HEIGHT = 0x07
# PR_ZOOMX = 0x08
# PR_COLOR = 0x09
# PR_BORDERWIDTH = 0x0a
# PR_BORDERCOLOR = 0x0b
# PR_ALPHA = 0x0c
"PR_PRIORITY": ParamType.Int,
# PR_OFFSETX = 0x0e
# PR_OFFSETY = 0x0f
# PR_FONTNAME = 0x10
"PR_FONTHEIGHT": ParamType.Int,
# PR_FONTSTYLE = 0x12
"PR_LINESPACE": ParamType.Int,
"PR_FONTCOLOR": ParamType.Int,
"PR_FONTLINKCOLOR": ParamType.Int,
"PR_FONTBORDERCOLOR": ParamType.Int,
"PR_FONTHOVERCOLOR": ParamType.Int,
# PR_FONTHOVERSTYLE = 0x18
# PR_HOVERCOLOR = 0x19
"PR_ANTIALIAS": ParamType.Flag,
# PR_DELAY = 0x1b
"PR_PAUSED": ParamType.Flag,
# PR_VOLUME = 0x1d
# PR_REPEAT = 0x1e
# PR_BALANCE = 0x1f
# PR_ANGLE = 0x20
# PR_ONPLAYING = 0x21
# PR_ONNOTIFY = 0x22
# PR_ONMOUSEMOVE = 0x23
# PR_ONMOUSEOUT = 0x24
# PR_ONLBTNDOWN = 0x25
# PR_ONLBTNUP = 0x26
# PR_ONRBTNDOWN = 0x27
# PR_ONRBTNUP = 0x28
# PR_ONWHEELDOWN = 0x29
# PR_ONWHEELUP = 0x2a
# PR_BRIGHTNESS = 0x2b
# PR_ONPLAYEND = 0x2c
# PR_INDEX = 0x2d
# PR_COUNT = 0x2e
# PR_ONLINK = 0x2f
"PR_VISIBLE": ParamType.Flag,
# PR_COLCOUNT = 0x31
# PR_ROWCOUNT = 0x32
# PR_TEXT = 0x33
# PR_MARGINX = 0x34
# PR_MARGINY = 0x35
# PR_HALIGN = 0x36
# PR_BORDERSOURCETL = 0x37
# PR_BORDERSOURCETC = 0x38
# PR_BORDERSOURCETR = 0x39
# PR_BORDERSOURCECL = 0x3a
# PR_BORDERSOURCECC = 0x3b
# PR_BORDERSOURCECR = 0x3c
# PR_BORDERSOURCEBL = 0x3d
# PR_BORDERSOURCEBC = 0x3e
# PR_BORDERSOURCEBR = 0x3f
# PR_BORDERHALIGNT = 0x40
# PR_BORDERHALIGNC = 0x41
# PR_BORDERHALIGNB = 0x42
# PR_BORDERVALIGNL = 0x43
# PR_BORDERVALIGNC = 0x44
# PR_BORDERVALIGNR = 0x45
# PR_SCROLLSOURCE = 0x46
# PR_CHECKSOURCE = 0x47
# PR_AUTOSCRAP = 0x48
# PR_ONSELECT = 0x49
# PR_RCLICKSCRAP = 0x4a
# PR_ONOPENING = 0x4b
# PR_ONOPENED = 0x4c
# PR_ONCLOSING = 0x4d
# PR_ONCLOSED = 0x4e
# PR_CARETX = 0x4f
# PR_CARETY = 0x50
"PR_IGNOREMOUSE": ParamType.Int,
"PR_TEXTPAUSED": ParamType.Flag,
# PR_TEXTDELAY = 0x53
# PR_HOVERSOURCE = 0x54
# PR_PRESSEDSOURCE = 0x55
# PR_GROUPINDEX = 0x56
# PR_ALLOWALLUP = 0x57
# PR_SELECTED = 0x58
# PR_CAPTUREMASK = 0x59
# PR_POWER = 0x5a
# PR_ORIGWIDTH = 0x5b
# PR_ORIGHEIGHT = 0x5c
# PR_APPEARX = 0x5d
# PR_APPEARY = 0x5e
# PR_PARTMOTION = 0x5f
# PR_PARAM = 0x60
# PR_PARAM2 = 0x61
# PR_TOPINDEX = 0x62
# PR_READONLY = 0x63
# PR_CURSOR = 0x64
# PR_POSZOOMED = 0x65
# PR_ONPLAYSTART = 0x66
# PR_PARAM3 = 0x67
# PR_ONMOUSEIN = 0x68
# PR_ONMAPIN = 0x69
# PR_ONMAPOUT = 0x6a
# PR_MAPSOURCE = 0x6b
# PR_AMP = 0x6c
# PR_WAVELEN = 0x6d
# PR_SCROLLX = 0x6e
# PR_SCROLLY = 0x6f
# PR_FLIPH = 0x70
# PR_FLIPV = 0x71
# PR_ONIDLE = 0x72
# PR_DISTANCEX = 0x73
# PR_DISTANCEY = 0x74
# PR_CLIPLEFT = 0x75
# PR_CLIPTOP = 0x76
# PR_CLIPWIDTH = 0x77
# PR_CLIPHEIGHT = 0x78
# PR_DURATION = 0x79
# PR_THUMBSOURCE = 0x7a
# PR_BUTTONSOURCE = 0x7b
# PR_MIN = 0x7c
# PR_MAX = 0x7d
# PR_VALUE = 0x7e
# PR_ORIENTATION = 0x7f
# PR_SMALLCHANGE = 0x80
# PR_LARGECHANGE = 0x81
# PR_MAPTEXT = 0x82
# PR_GLYPHWIDTH = 0x83
# PR_GLYPHHEIGHT = 0x84
# PR_ZOOMY = 0x85
# PR_CLICKEDSOURCE = 0x86
# PR_ANIPAUSED = 0x87
# PR_ONHOLD = 0x88
# PR_ONRELEASE = 0x89
# PR_REVERSE = 0x8a
# PR_PLAYING = 0x8b
# PR_REWINDONLOAD = 0x8c
# PR_COMPOTYPE = 0x8d
"PR_FONTSHADOWCOLOR": ParamType.Int,
"PR_FONTBORDER": ParamType.Int,
"PR_FONTSHADOW": ParamType.Int,
# PR_ONKEYDOWN = 0x91
# PR_ONKEYUP = 0x92
# PR_ONKEYREPEAT = 0x93
"PR_HANDLEKEY": ParamType.Flag,
# PR_ONFOCUSIN = 0x95
# PR_ONFOCUSOUT = 0x96
# PR_OVERLAY = 0x97
# PR_TAG = 0x98
"PR_CAPTURELINK": ParamType.Flag,
# PR_FONTHOVERBORDER = 0x9a
# PR_FONTHOVERBORDERCOLOR = 0x9b
# PR_FONTHOVERSHADOW = 0x9c
# PR_FONTHOVERSHADOWCOLOR = 0x9d
# PR_BARSIZE = 0x9e
# PR_MUTEONLOAD = 0x9f
# PR_PLUSX = 0xa0
# PR_PLUSY = 0xa1
# PR_CARETHEIGHT = 0xa2
# PR_REPEATPOS = 0xa3
# PR_BLURSPAN = 0xa4
# PR_BLURDELAY = 0xa5
"PR_FONTCHANGEABLED": ParamType.Flag,
# PR_IMEMODE = 0xa7
# PR_FLOATANGLE = 0xa8
# PR_FLOATZOOMX = 0xa9
# PR_FLOATZOOMY = 0xaa
# PR_CAPMASKLEVEL = 0xab
# PR_PADDINGLEFT = 0xac
# PR_PADDING_RIGHT = 0xad
}
def _check_string_literal(value):
if value.startswith('"'):
value = value[1:]
else:
print(
'Warning: String literals should be entered as double quoted (") strings, '
'assuming you meant to enter "{}"'.format(value)
)
if value.endswith('"'):
value = value[:-1]
else:
print(
'Warning: String literals should be entered as double quoted (") strings, '
'assuming you meant to enter "{}"'.format(value)
)
return value
def _edit_parser_op(op, prompt="Operand"):
if op.type == ParamType.Str:
orig = '"{}"'.format(op.value)
else:
orig = op.value
value = click.prompt(prompt, default=orig)
if value != orig:
if op.type == ParamType.Str:
value = _check_string_literal(value)
elif op.type in (ParamType.Flag, ParamType.Int):
try:
value = int(value)
except ValueError:
print("Expected an integer value, skipping field")
return
elif op.type == ParamType.Float:
try:
value = float(value)
except ValueError:
print("Expected a floating point value, skipping field")
return
elif op.type == ParamType.Var and value.startswith('"'):
print('Expected a variable name, var names cannot start with ", skipping field')
return
op.value = value
def _edit_delimited_string_op(str_op, sep_op, prompt="String"):
"""Edit delimited string str_op (delimited by sep_op)."""
if str_op.type != ParamType.Str or str_op.type != ParamType.Str:
print("Expected a delimited string and separator, skipping field.")
return
new_strs = []
sep = sep_op.value
for i, s in enumerate(str_op.value.split(sep)):
while True:
value = click.prompt("{} {}".format(prompt, i), default='"{}"'.format(s))
if sep in value:
print(' Entry strings cannot contain the delimiter string ("{}")')
else:
break
new_strs.append(_check_string_literal(value))
str_op.value = sep.join(new_strs)
value = click.prompt("{} separator".format(prompt), default='"{}"'.format(sep))
sep_op.value = _check_string_literal(value)
def _edit_parser(parser):
"""Edit fields in a TLiveParser."""
# map ____<arg> variables to the appropriate entry index for this parser
print(" {}".format(parser))
entry_index = {}
for i, entry in enumerate(parser.entries):
if entry.type == OpeDataType.To and entry.name.startswith("____"):
if len(entry.operands) != 1:
print("Got unexpected OpeDataType.To entry")
continue
entry_index[entry.name] = i
elif entry.type == OpeDataType.Func:
if entry.func == OpeFuncType.AddArray:
# Format should be AddArray(<array_variable>, <value>)
if len(entry.operands) != 2:
print("Skipping complex AddArray entry")
continue
array_var_op = entry.operands[0]
if array_var_op.type != ParamType.Var:
print("AddArray operand 0 is not a variable name: {}".format(entry))
continue
value_entry_index = entry_index.get(entry.operands[1].value)
if value_entry_index is None:
print("AddArray operand 1 does not point to a valid parser ____<arg> entry: {}".format(entry))
continue
value_entry_op = parser.entries[value_entry_index].operands[0]
_edit_parser_op(array_var_op, " Array variable")
_edit_parser_op(value_entry_op, " Array entry")
elif entry.func == OpeFuncType.StringToArray:
# Format should be StringToArray(<delimited_string>,
# <array_variable>, <separator>)
# where array entries are delimited by <separator>.
#
# i.e. StringToArray("foo,bar", my_array, ",") sets
# my_array = ["foo", "bar"]
#
# NOTE: we allow editing of array entry strings for translation
# purposes, but do not allow adding or removing entire entries
# since modifying the array length would most likely break LM
# core engine scripts.
if len(entry.operands) != 3 or (
entry.operands[0].type != ParamType.Str
or entry.operands[1].type != ParamType.Var
or entry.operands[2].type != ParamType.Var
):
print("Skipping unexpected StringToArray entry")
continue
sep_entry_index = entry_index.get(entry.operands[2].value)
if sep_entry_index is None:
print("StringToArray operand 2 does not point to a valid parser ____<arg> entry: {}".format(entry))
continue
sep_entry_op = parser.entries[sep_entry_index].operands[0]
_edit_parser_op(entry.operands[1], " Array variable")
_edit_delimited_string_op(entry.operands[0], sep_entry_op, " Array entry")
else:
print("Skipping uneditable parser func type: {}".format(entry))
elif entry.type == OpeDataType.To:
if len(entry.operands) > 1:
print("Skipping complex assignment")
continue
value = click.prompt(" Destination variable", entry.name)
if value != entry.name:
entry.name = value
_edit_parser_op(entry.operands[0], " Value")
elif entry.type in (
OpeDataType.Equal,
OpeDataType.Big,
OpeDataType.Small,
OpeDataType.EBig,
OpeDataType.ESmall,
OpeDataType.NEqual,
):
# boolean comparison
lhs_op = entry.operands[0]
if lhs_op.type == ParamType.Var:
if lhs_op.value.startswith("____"):
index = entry_index.get(lhs_op.value)
if index is None:
print("Comparison operand 0 does not point to a valid parser ____<arg> entry")
continue
lhs_op = parser.entries[index].operands[0]
_edit_parser_op(lhs_op, " Left hand side")
rhs_op = entry.operands[1]
if rhs_op.type == ParamType.Var:
if rhs_op.value.startswith("____"):
index = entry_index.get(rhs_op.value)
if index is None:
print("Comparison operand 1 does not point to a valid parser ____<arg> entry")
continue
rhs_op = parser.entries[index].operands[0]
_edit_parser_op(rhs_op, " Right hand side")
else:
print("Skipping uneditable parser entry: {}".format(entry))
def _edit_calc(cmd):
"""Edit a Calc command.
Note:
Only a limited set of OpeFuncType calc types can be edited.
"""
parser = cmd.get("Calc")
if not parser:
print("Skipping empty Calc() command.")
return
print()
print("Editing Calc expression")
_edit_parser(parser)
def _edit_component(cmd):
"""Edit a BaseComponent (or subclass) command."""
print()
print("Enter new value for each field (or keep existing value)")
for key in cmd._component_keys:
parser = cmd[key]
# TODO: editing complex fields and adding values for empty fields will
# require full LiveParser expression parsing, for now we can only edit
# simple scalar values.
if (
len(parser.entries) > 1
or (len(parser.entries) == 1 and parser.entries[0].type != OpeDataType.To)
or (len(parser.entries) == 0 and key not in EDITABLE_PROPERTY_TYPES)
):
print("{} [{}]: <skipping uneditable field>".format(key, parser))
continue
if parser.entries:
e = parser.entries[0]
op = e.operands[-1]
if op:
value = click.prompt(key, default=op.value)
if value != op.value:
if op.type == ParamType.Int or op.type == ParamType.Flag:
op.value = int(value)
elif op.type == ParamType.Float:
op.value = numpy.longdouble(value)
else:
op.value = value
else:
value = click.prompt(key, default="")
if value:
param_type = EDITABLE_PROPERTY_TYPES[key]
try:
if param_type == ParamType.Int or param_type == ParamType.Flag:
value = int(value)
elif param_type == ParamType.Float:
value = numpy.longdouble(value)
op = Param(value, param_type)
e = OpeData(type=OpeDataType.To, name="____arg", operands=[op])
parser.entries.append(e)
except ValueError:
print("Invalid datatype for {}, skipping.".format(key))
def _edit_jump(cmd):
"""Edit a jump command."""
page = cmd.get("Page")
if not page:
print("Skipping Jump() command with no jump target")
print()
value = click.prompt("Jump target page", page.Page)
if value != page.Page:
page.Page = value
value = click.prompt("Jump target label ID", page.Label)
if value != page.Label:
page.Label = value
parser = cmd.get("Calc")
if not parser:
# conditional calc optional
return
print("Editing jump condition expression")
_edit_parser(parser)
@lmlsb.command()
@click.argument("lsb_file", required=True, type=click.Path(exists=True, dir_okay=False))
@click.argument("line_number", required=True, type=int)
def edit(lsb_file, line_number):
"""Edit the specified command within an LSB file.
Only specific command types and specific fields can be edited.
The original LSB file will be backed up to <lsb_file>.bak
WARNING: This command should only be used by advanced users familiar with the LiveMaker engine.
Improper use of this command may cause undefined behavior (or a complete crash)
in the LiveMaker engine during runtime.
Note: Setting empty fields to improper data types may cause
undefined behavior in the LiveMaker engine. When editing a field,
the data type of the new value is assumed to be the same as the
original data type.
"""
with open(lsb_file, "rb") as f:
try:
lsb = LMScript.from_file(f)
except LiveMakerException as e:
sys.exit("Could not open LSB file: {}".format(e))
cmd = None
for c in lsb.commands:
if c.LineNo == line_number:
cmd = c
break
else:
sys.exit("Command {} does not exist in the specified LSB".format(line_number))
print("{}: {}".format(line_number, str(cmd).replace("\r", "\\r").replace("\n", "\\n")))
if isinstance(cmd, BaseComponentCommand):
_edit_component(cmd)
elif isinstance(cmd, Calc):
_edit_calc(cmd)
elif isinstance(cmd, Jump):
_edit_jump(cmd)
else:
sys.exit("Cannot edit {} commands.".format(cmd.type.name))
print("Backing up original LSB.")
shutil.copyfile(str(lsb_file), "{}.bak".format(str(lsb_file)))
try:
new_lsb_data = lsb.to_lsb()
with open(lsb_file, "wb") as f:
f.write(new_lsb_data)
print("Wrote new LSB.")
except LiveMakerException as e:
sys.exit("Could not generate new LSB file: {}".format(e))
[docs]def insert_lns(encoding, lsb_file, script_file, line_number, no_backup):
"""Compile specified LNS script and insert it into the specified LSB file.
The LSB command at line_number must be a TextIns command. The existing text
block of the specified TextIns command will be replaced with the new one from
script_file.
script_file should be an LNS script which was initially generated by lmlsb extract.
The original LSB file will be backed up to <lsb_file>.bak unless the
--no-backup option is specified.
"""
# TODO: modify the function so that it doesn't write new file for each script during batch insert
with open(script_file, "rb") as f:
script = f.read().decode(encoding)
try:
cc = LNSCompiler()
new_body = cc.compile(script)
except LiveMakerException as e:
sys.exit("Could not compile script file: {}".format(e))
with open(lsb_file, "rb") as f:
try:
lsb = LMScript.from_file(f)
except LiveMakerException as e:
sys.exit("Could not open LSB file: {}".format(e))
for index, name, scenario in lsb.text_scenarios():
if index == line_number:
print("Scenario {} at line {} will be replaced.".format(name, index))
scenario.replace_body(new_body, ruby_text=cc.ruby_text)
break
else:
sys.exit("No matching TextIns command in the specified LSB.")
if not no_backup:
print("Backing up original LSB.")
shutil.copyfile(str(lsb_file), "{}.bak".format(str(lsb_file)))
try:
new_lsb_data = lsb.to_lsb()
with open(lsb_file, "wb") as f:
f.write(new_lsb_data)
print("Wrote new LSB.")
except LiveMakerException as e:
sys.exit("Could not generate new LSB file: {}".format(e))
CSV_HEADER = ["ID", "Label", "Context", "Original text", "Translated text"]
@lmlsb.command()
@click.argument("lsb_file", required=True, type=click.Path(exists=True, dir_okay="False"))
@click.argument("csv_file", required=True, type=click.Path(exists=False, dir_okay="False"))
@click.option(
"-e",
"--encoding",
type=click.Choice(["cp932", "utf-8", "utf-8-sig"]),
default="utf-8",
help="Output text encoding (defaults to utf-8).",
)
@click.option("--lpm", is_flag=True, default=False, help="Include LPM (image) based menus in the extracted output.")
@click.option("--overwrite", is_flag=True, default=False, help="Overwrite existing csv file.")
@click.option("--append", is_flag=True, default=False, help="Append menu data to existing csv file.")
def extractmenu(lsb_file, csv_file, encoding, lpm, overwrite, append):
"""Extract menu choices from the given LSB file to a CSV file.
You can open this CSV file for translation in most spreadsheet programs (Excel, Open/Libre Office Calc, etc).
Just remember to choose comma as delimiter and " as quotechar.
NOTE: If you are using Excel and UTF-8 text, you must also specify --encoding=utf-8-sig, since Excel requires
UTF-8 with BOM to handle UTF-8 properly.
You can use the --append option to add the text data from this lsb file to a existing csv.
With the --overwrite option an existing csv will be overwritten without warning.
"""
print("Extracting {} ...".format(lsb_file))
try:
pylm = PylmProject(lsb_file)
call_name = pylm.call_name(lsb_file)
except LiveMakerException:
pylm = None
call_name = None
try:
with open(lsb_file, "rb") as f:
lsb = LMScript.from_file(f, call_name=call_name, pylm=pylm)
except BadLsbError as e:
sys.exit("Failed to parse file: {}".format(e))
if pylm:
pylm.update_labels(lsb)
csv_data = []
names = set()
for id_, choice in lsb.get_menu_choices():
if isinstance(choice, LPMSelectionChoice):
if not lpm:
continue
text = f"{choice.name} (Image: {choice.src_file})"
else:
text = choice.text
name = id_.name
if name in names:
name = ""
else:
names.add(name)
if pylm:
_, target_name = pylm.resolve_label(choice.target)
else:
target_name = None
target_name = f" ({target_name})" if target_name else ""
context = [f"Target: {choice.target}{target_name}"]
csv_data.append([str(id_), name, "\n".join(context), text, None])
if len(csv_data) == 0:
sys.exit("No menu data found.")
if Path(csv_file).exists():
if not overwrite and not append:
sys.exit("File {} already exists. Please use --overwrite or --append option.".format(csv_file))
elif append:
print("File {} does not exist, but --append specified. A new file will be created.".format(csv_file))
append = False
with open(csv_file, ("a" if append else "w"), encoding=encoding, newline="\n") as csvfile:
csv_writer = csv.writer(csvfile, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL)
if not append:
csv_writer.writerow(CSV_HEADER)
for row in csv_data:
csv_writer.writerow(row)
print("{} Menu entries extracted.".format(len(csv_data)))
@lmlsb.command()
@click.argument("lsb_file", required=True, type=click.Path(exists=True, dir_okay="False"))
@click.argument("csv_file", required=True, type=click.Path(exists=False, dir_okay="False"))
@click.option(
"-e",
"--encoding",
type=click.Choice(["cp932", "utf-8", "utf-8-sig"]),
default="utf-8",
help="Output text encoding (defaults to utf-8).",
)
@click.option("--no-backup", is_flag=True, default=False, help="Do not generate backup of original lsb file.")
@click.option("-v", "--verbose", is_flag=True, default=False)
def insertmenu(lsb_file, csv_file, encoding, no_backup, verbose):
"""Apply translated menu choices from the given CSV file to given LSB file.
CSV_FILE should be a file previously created by the extractmenu command, with added translations.
--encoding option must match the values were used for extractmenu.
LPM image menus cannot be edited, to translate LPM menus, replace the relevant GAL image files.
The original LSB file will be backed up to <lsb_file>.bak unless the --no-backup option is specified.
"""
lsb_file = Path(lsb_file)
print("Patching {} ...".format(lsb_file))
try:
pylm = PylmProject(lsb_file)
call_name = pylm.call_name(lsb_file)
except LiveMakerException:
pylm = None
call_name = None
try:
with open(lsb_file, "rb") as f:
lsb = LMScript.from_file(f, pylm=pylm, call_name=call_name)
except BadLsbError as e:
sys.exit("Failed to parse file: {}".format(e))
csv_data = []
with open(csv_file, newline="\n", encoding=encoding) as csvfile:
csv_reader = csv.reader(csvfile, delimiter=",", quotechar='"')
for row in csv_reader:
csv_data.append(row)
translated, failed, untranslated = _patch_csv_menus(lsb, lsb_file, csv_data, verbose)
print(f" Translated {translated} choices")
print(f" Failed to translate {failed} choices")
print(f" Ignored {untranslated} untranslated choices")
if not translated:
return
if not no_backup:
print("Backing up original LSB.")
shutil.copyfile(str(lsb_file), "{}.bak".format(str(lsb_file)))
try:
new_lsb_data = lsb.to_lsb()
with open(lsb_file, "wb") as f:
f.write(new_lsb_data)
print("Wrote new LSB.")
except LiveMakerException as e:
sys.exit("Could not generate new LSB file: {}".format(e))
def _patch_csv_menus(lsb, lsb_file, csv_data, verbose=False):
"""Patch text menus in lsb using csv_data."""
text_objects = []
untranslated = 0
for row, (id_str, name, context, orig_text, translated_text) in enumerate(csv_data):
try:
id_ = make_identifier(id_str)
except BadTextIdentifierError as e:
if row > 0:
# ignore possible header row
print(f"Ignoring invalid text ID: {e}")
continue
if not isinstance(id_, TextMenuIdentifier):
continue
if id_.filename == lsb_file.name:
if translated_text:
if verbose:
print(f"{id_}: '{orig_text}' -> '{translated_text}'")
text_objects.append((id_, translated_text))
else:
if verbose:
print(f"{id_} Ignoring untranslated text '{orig_text}'")
untranslated += 1
translated, failed = lsb.replace_text(text_objects)
return translated, failed, untranslated
@lmlsb.command()
@click.argument("lsb_file", required=True, type=click.Path(exists=True, dir_okay="False"))
@click.argument("csv_file", required=True, type=click.Path(exists=False, dir_okay="False"))
@click.option(
"-e",
"--encoding",
type=click.Choice(["cp932", "utf-8", "utf-8-sig"]),
default="utf-8",
help="Output text encoding (defaults to utf-8).",
)
@click.option("--overwrite", is_flag=True, default=False, help="Overwrite existing csv file.")
@click.option("--append", is_flag=True, default=False, help="Append text data to existing csv file.")
def extractcsv(lsb_file, csv_file, encoding, overwrite, append):
"""Extract text from the given LSB file to a CSV file.
You can open this CSV file for translation in most spreadsheet programs (Excel, Open/Libre Office Calc, etc).
Just remember to choose comma as delimiter and " as quotechar.
NOTE: If you are using Excel and UTF-8 text, you must also specify --encoding=utf-8-sig, since Excel requires
UTF-8 with BOM to handle UTF-8 properly.
You can use the --append option to add the text data from this lsb file to a existing csv.
With the --overwrite option an existing csv will be overwritten without warning.
NOTE: Formatting tags will be lost when using this command in conjunction with insertcsv.
For translating games which use formatting tags, you may need to work directly with LNS scripts
using the extract and insert/batchinsert commands.
"""
lsb_file = Path(lsb_file)
print("Extracting {} ...".format(lsb_file))
try:
pylm = PylmProject(lsb_file)
call_name = pylm.call_name(lsb_file)
except LiveMakerException:
pylm = None
call_name = None
try:
with open(lsb_file, "rb") as f:
lsb = LMScript.from_file(f, call_name=call_name, pylm=pylm)
except BadLsbError as e:
sys.exit("Failed to parse file: {}".format(e))
csv_data = []
for id_, block in lsb.get_text_blocks():
csv_data.append([str(id_), id_.name, block.name_label, block.text, None])
if len(csv_data) == 0:
sys.exit("No text data found.")
if Path(csv_file).exists():
if not overwrite and not append:
sys.exit("File {} already exists. Please use --overwrite or --append option.".format(csv_file))
elif append:
print("File {} does not exist, but --append specified. A new file will be created.".format(csv_file))
append = False
with open(csv_file, ("a" if append else "w"), newline="\n", encoding=encoding) as csvfile:
csv_writer = csv.writer(csvfile, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL)
if not append:
csv_writer.writerow(CSV_HEADER)
for row in csv_data:
csv_writer.writerow(row)
print(f"Extracted {len(csv_data)} text blocks.")
def _patch_csv_text(lsb, lsb_file, csv_data, verbose=False):
"""Patch text lines in the given lsb file using csv_data."""
text_objects = []
untranslated = 0
for row, (id_str, name, context, orig_text, translated_text) in enumerate(csv_data):
try:
id_ = make_identifier(id_str)
except BadTextIdentifierError as e:
if row > 0:
# ignore possible header row
print(f"Ignoring invalid text ID: {e}")
continue
if not isinstance(id_, TextBlockIdentifier):
continue
if id_.filename == lsb_file.name:
if translated_text:
if verbose:
print(f"{id_}: '{orig_text}' -> '{translated_text}'")
text_objects.append((id_, translated_text))
else:
if verbose:
print(f"{id_} Ignoring untranslated text '{orig_text}'")
untranslated += 1
translated, failed = lsb.replace_text(text_objects)
return translated, failed, untranslated
@lmlsb.command()
@click.argument("lsb_file", required=True, type=click.Path(exists=True, dir_okay="False"))
@click.argument("csv_file", required=True, type=click.Path(exists=False, dir_okay="False"))
@click.option(
"-e",
"--encoding",
type=click.Choice(["cp932", "utf-8", "utf-8-sig"]),
default="utf-8",
help="Output text encoding (defaults to utf-8).",
)
@click.option("--no-backup", is_flag=True, default=False, help="Do not generate backup of original lsb file.")
@click.option("-v", "--verbose", is_flag=True, default=False)
def insertcsv(lsb_file, csv_file, encoding, no_backup, verbose):
"""Apply translated text lines from the given CSV file to given LSB file.
CSV_FILE should be a file previously created by the extractcsv command, with added translations.
--encoding option must match the values were used for extractcsv.
The original LSB file will be backed up to <lsb_file>.bak unless the --no-backup option is specified.
"""
lsb_file = Path(lsb_file)
print("Patching {} ...".format(lsb_file))
try:
pylm = PylmProject(lsb_file)
call_name = pylm.call_name(lsb_file)
except LiveMakerException:
pylm = None
call_name = None
try:
with open(lsb_file, "rb") as f:
lsb = LMScript.from_file(f, call_name=call_name, pylm=pylm)
except BadLsbError as e:
sys.exit("Failed to parse file: {}".format(e))
csv_data = []
with open(csv_file, newline="\n", encoding=encoding) as csvfile:
csv_reader = csv.reader(csvfile, delimiter=",", quotechar='"')
for row in csv_reader:
csv_data.append(row)
translated, failed, untranslated = _patch_csv_text(lsb, lsb_file, csv_data, verbose)
print(f" Translated {translated} lines")
print(f" Failed to translate {failed} lines")
print(f" Ignored {untranslated} untranslated lines")
if not translated:
return
if not no_backup:
print("Backing up original LSB.")
shutil.copyfile(str(lsb_file), "{}.bak".format(str(lsb_file)))
try:
new_lsb_data = lsb.to_lsb()
with open(lsb_file, "wb") as f:
f.write(new_lsb_data)
print("Wrote new LSB.")
except LiveMakerException as e:
sys.exit("Could not generate new LSB file: {}".format(e))