Source code for livemaker.cli.lmgraph
# -*- 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/>.
# -*- coding: utf-8 -*-
"""pylivemaker lsb call graph tool."""
import sys
from collections import deque
from pathlib import Path
import click
import pydot
from livemaker.exceptions import LiveMakerException
from livemaker.lsb import LMScript
from livemaker.lsb.command import CommandType
from livemaker.lsb.graph import make_graph, nx_to_dot
from .cli import _version, __version__
@click.group()
@click.version_option(version=__version__, message=_version)
def lmgraph():
"""Experimental command-line tool for generating DOT syntax graphs."""
IGNORED_SCRIPTS = [
"メッセージボックス作成.lsb",
"メッセージボックス座標.lsb",
]
visited = set()
lsbs_to_visit = deque()
graph = pydot.Dot(graph_type="digraph")
[docs]def parse_lsb(lsb_file, root_dir=None):
"""Parse one LSB into the graph."""
if root_dir:
path = root_dir.joinpath(lsb_file)
else:
path = lsb_file
if path in visited:
return
visited.add(path)
print("processing {}...".format(path))
with open(path, "rb") as f:
try:
lsb = LMScript.from_file(f)
except LiveMakerException as e:
sys.exit("Could not open LSB file: {}".format(e))
graph.add_node(pydot.Node(str(lsb_file), label=str(lsb_file)))
remaining_cmds = set(range(1, len(lsb.commands)))
# very naive attempt at determining condition for jumping to a new script
cmds_to_visit = deque([(0, None)])
while cmds_to_visit:
pc, last_calc = cmds_to_visit.popleft()
cmd = lsb.commands[pc]
if cmd.type == CommandType.Jump:
ref = cmd.get("Page")
calc = str(cmd.get("Calc"))
if ref.Page == lsb_file:
if calc != "1":
# branch not taken
next_pc = pc + 1
if next_pc in remaining_cmds:
remaining_cmds.remove(next_pc)
cmds_to_visit.append((next_pc, last_calc))
if calc != "0":
# branch taken
if calc == "1":
calc = last_calc
next_pc = ref.Label
if next_pc in remaining_cmds:
remaining_cmds.remove(next_pc)
cmds_to_visit.append((next_pc, calc))
elif not ref.Page.startswith("ノベルシステム"):
if last_calc:
edge = pydot.Edge(lsb_file, ref.Page, label=last_calc)
else:
edge = pydot.Edge(lsb_file, ref.Page)
graph.add_edge(edge)
lsbs_to_visit.append(ref.Page)
elif cmd.type == CommandType.Call:
ref = cmd.get("Page")
calc = str(cmd.get("Calc"))
if ref.Page != lsb_file and not ref.Page.startswith("ノベルシステム") and ref.Page not in IGNORED_SCRIPTS:
# ignore calls to self (used for cleanup sometimes) and
# novel system calls
if last_calc:
edge = pydot.Edge(lsb_file, ref.Page, label=last_calc)
else:
edge = pydot.Edge(lsb_file, ref.Page)
graph.add_edge(edge)
lsbs_to_visit.append(ref.Page)
next_pc = pc + 1
if next_pc in remaining_cmds:
remaining_cmds.remove(next_pc)
cmds_to_visit.append((next_pc, last_calc))
elif cmd.type not in (CommandType.Exit, CommandType.Terminate, CommandType.PCReset):
next_pc = pc + 1
if next_pc in remaining_cmds:
remaining_cmds.remove(next_pc)
cmds_to_visit.append((next_pc, last_calc))
@lmgraph.command()
@click.argument("lsb_file", required=True, type=click.Path(exists=True, dir_okay=False))
@click.argument("out_file", required=False)
def game(lsb_file, out_file):
"""Generate a DOT syntax call graph for a full LiveNovel game.
lsb_file should be a path to the root script node - this should always be ゲームメイン.lsb (game_main.lsb)
for LiveMaker games.
If output file is not specified, it defaults to <lsb_file>.dot
The output graph will start with game_main as the root node and follow branches to all scenario
scripts, which should give a general approximation of the original LiveMaker scenario chart.
"""
path = Path(lsb_file)
print("Generating graph for {}".format(path))
if path.name != "ゲームメイン.lsb":
print("Warning: input filename is not ゲームメイン.lsb")
root_dir = path.parent
lsbs_to_visit.append(path.name)
while lsbs_to_visit:
parse_lsb(lsbs_to_visit.popleft(), root_dir=root_dir)
if not out_file:
out_file = "{}.dot".format(lsb_file)
with open(out_file, "w") as f:
f.write(graph.to_string())
print("Wrote {}".format(out_file))
@lmgraph.command()
@click.argument("lsb_file", required=True, type=click.Path(exists=True, dir_okay=False))
@click.argument("out_file", required=False)
def lsb(lsb_file, out_file):
"""Generate a DOT syntax execution graph for an LSB script.
lsb_file should be an LSB file.
If output file is not specified, it defaults to <lsb_file>.dot
The output graph will contain blocks of LSB commands as nodes
and branch points as edges.
"""
path = Path(lsb_file)
print("Generating execution graph for {}".format(path))
if not out_file:
out_file = "{}.dot".format(lsb_file)
lsb = LMScript.from_file(path)
dot = nx_to_dot(make_graph(lsb))
with open(out_file, "w") as f:
f.write(dot.to_string())
print("Wrote {}".format(out_file))