#!/usr/bin/python3
# Copyright (c) 2021-2024 Pixel Grass
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# Algorithm example for the 4:3 to 16:9/widescreen conversion case:
# w - screen width (according to localcoord),
# b1 - old camera's horizontal bound value (abs/radius), d1 - old 4:3 horizontal delta,
# b2 - new camera's horizontal bound value (abs/radius), d2 - new 16:9 horizontal delta.
#
# Case diagram:
#
# d1 * b1
# |-------------------|
# d2 * b2 1/6w 1/2w
# |------------|------|------------------|
# off-screen ^ ^ ^
# | +-- 4:3 edge +-- screen center
# +-- 16:9 edge
#
# Bound/delta equations:
#
# 1) according to the diagram:
# d1 * b1 - d2 * b2 = 1/6 * w
# d2 * b2 = d1 * b1 - 1/6 * w
# d2 = (d1 * b1 - 1/6 * w) / b2
#
# 2) substitute b2 with the following bound modification formula:
# b2 = b1 - 1/6 * w
#
# 3) final deltas modification formula:
# d2 = (d1 * b1 - 1/6 * w) / (b1 - 1/6 * w)
import argparse
import re
import shutil
import sys
from dataclasses import dataclass
from decimal import Decimal, Context, ROUND_HALF_DOWN
from pathlib import Path
from typing import Any, Callable, Optional, Pattern
MODE_MUGEN = "mugen".casefold()
MODE_IKEMEN = "ikemen".casefold()
MODE_NONE = "none".casefold()
BOUNDS_MODE_VALUES = [MODE_MUGEN, MODE_IKEMEN, MODE_NONE]
BOUNDS_MODES = " | ".join(BOUNDS_MODE_VALUES)
CMD_PARSER = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter, description="""
A stage DEF file converter for M.U.G.E.N/Ikemen that allows e.g. to adjust a zoom-less 4:3 stage
to support a widescreen aspect ratio, by adjusting zoom, horizontal deltas and other parameters.
Alternatively, this tool can also adjust a zoom-less stage to support a target zoom level.
When using this tool to enable widescreen support, then the resulting stage is meant to
be displayed with the `StageFit` parameter (from the `mugen.cfg` config file) set to `0`.
This converter works by setting the `zoomin` and `zoomout` values and then calculating and adjusting
the `boundleft` and `boundright` parameters, as well as the horizontal `delta` values of backgrounds,
including the old-style `parallax` type backgrounds using the `xscale` parameter.
The players' starting position values of `p1startx` and `p2startx` are also adjusted appropriately.
The camera's vertical bounding parameters are also tweaked, depending on the target engine.
Unfortunately, this part is mostly guesswork, so manual follow-up adjustments will be necessary,
however it should still be much easier than editing the whole stage completely by hand. To adjust
the vertical camera bounds after conversion, use e.g. `boundhigh`, `cutlow` and similar parameters.
It should also be mentioned, that Mugen's and Ikemen's vertical camera bounding algorithms work
very differently, especially in the case of a wide zoom range.
The parallax floors using the `xscale` parameter should also receive automatic delta adjustments,
however these may need to be tweaked after conversion.
Stages already having zoom are not supported. Highres stages using `highres` are also
not supported. Highres stages using `localcoord` and/or `(x/y)scale` should theoretically
be supported, however this has not been extensively tested.
""", epilog="""
examples:
%(prog)s DEF_FILE
modify the given zoom-less stage DEF file to support a 16:9 aspect ratio
(this is the default behavior when no additional options are given)
%(prog)s -z 0.85 DEF_FILE
modify the given zoom-less stage DEF file to support a zoom of 0.85
the stage will be set to always display in this exact zoom
%(prog)s -z 0.8 -n 1.0 DEF_FILE
modify the given zoom-less stage DEF file to support a zoom-out of 0.8
set the zoom-in value to 1.0, making the effective zoom range [0.8, 1.0]
%(prog)s -r 64x27 DEF_FILE
modify the given zoom-less stage DEF file to support the ultra-wide 64:27 aspect ratio
(this implies a large zoom-out value, therefore some artifacts may not be avoided)
Copyright (c) 2021-2023 Pixel Grass, License: MIT
** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. **
""")
CMD_PARSER.add_argument("stage_def_files", metavar="DEF_FILE", type=Path, nargs="+", help="""
the stage DEF file (multiple files can be given)\nthe files will be modified in place!
""".strip())
CMD_PARSER.add_argument("-r", "--target-aspect-ratio", metavar="RATIO", help="""
adjust the stage to support the given widescreen aspect ratio (e.g. 16x9)
mutually exclusive with the `--target-zoom` option: use only one or the other
""".strip())
CMD_PARSER.add_argument("-z", "--target-zoom", metavar="ZOOM", type=float, help="""
adjust the stage to support at least the given zoom value (e.g. 0.75)
warning: setting this value too low may introduce artifacts
mutually exclusive with the `--target-aspect-ratio` option: use only one or the other
""".strip())
CMD_PARSER.add_argument("-n", "--set-zoom-in", metavar="ZOOM_IN", type=float, help="""
set/override the `zoomin` camera parameter to the given value
(default: calculated from the target aspect ratio or the same as target zoom)
""".strip())
CMD_PARSER.add_argument("-t", "--set-zoom-out", metavar="ZOOM_OUT", type=float, help="""
set/override the `zoomout` camera parameter to the given value
warning: setting this value lower than the target zoom *will* introduce artifacts
(default: calculated from the target aspect ratio or the same as target zoom)
""".strip())
CMD_PARSER.add_argument("-m", "--bounds-mode", metavar="CB_MODE", help=f"""
choose camera's bounding mode (values: {BOUNDS_MODES}, default: {MODE_MUGEN})
* the `{MODE_MUGEN}` mode enables hacks for MUGEN's quirks, to possibly avoid artifacts
* the `{MODE_IKEMEN}` mode disables stage height cutting
* the `{MODE_NONE}` mode leaves the camera's vertical bounding parameters unchanged
""".strip())
CMD_PARSER.add_argument("-p", "--no-parallax", default=False, action='store_true', help="""
do not modify the parallax backgrounds (default: modify parallax backgrounds)
""".strip())
CMD_PARSER.add_argument("-d", "--debug-bg", default=False, action='store_true', help="""
set the `debugbg` property to `1` in the modified stage DEF file (default: do not set)
""".strip())
CMD_PARSER.add_argument("-o", "--output-path", metavar="OUT_PATH", type=Path, help="""
write the modified stage DEF file or files to the given output file or
directory respectively, instead of modifying the given DEF files in-place
""".strip())
CMD_PARSER.add_argument('--no-backup', default=False, action='store_true', help="""
do not create a backup for the modified stage DEF files
all of the file will be irreversibly modified in place!
""".strip())
CMD_PARSER.add_argument('--version', action='version', version="%(prog)s 1.1.0")
INFO = "Info".casefold()
STAGE_INFO = "StageInfo".casefold()
PLAYER_INFO = "PlayerInfo".casefold()
CAMERA = "Camera".casefold()
BG_DEF = "BGdef".casefold()
MUGEN_VERSION = "mugenversion".casefold()
LOCAL_COORD = "localcoord".casefold()
HIRES = "hires".casefold()
BOUND_HIGH = "boundhigh".casefold()
BOUND_LEFT = "boundleft".casefold()
BOUND_RIGHT = "boundright".casefold()
CUT_LOW = "cutlow".casefold()
ZOOM_IN = "zoomin".casefold()
ZOOM_OUT = "zoomout".casefold()
VERTICAL_FOLLOW = "verticalfollow".casefold()
P1_START_X = "p1startx".casefold()
P2_START_X = "p2startx".casefold()
TYPE = "type".casefold()
DELTA = "delta".casefold()
XSCALE = "xscale".casefold()
WINDOW = "window".casefold()
DEBUG_BG = "debugbg".casefold()
PARALLAX = "parallax".casefold()
VALUES_COUNT = {
MUGEN_VERSION: 1, LOCAL_COORD: 2, HIRES: 1,
BOUND_HIGH: 1, BOUND_LEFT: 1, BOUND_RIGHT: 1, CUT_LOW: 1,
ZOOM_IN: 1, ZOOM_OUT: 1, VERTICAL_FOLLOW: 1,
P1_START_X: 1, P2_START_X: 1, DELTA: {1, 2}, WINDOW: 4,
BG_DEF: 1, TYPE: 1
}
DECIMAL_PLACES = 8
WINDOW_TOLERANCE = 5
VERTICAL_FOLLOW_MIN = 0.00000001
MARGIN_FACTOR = {MODE_MUGEN: 1.0 / 30.0, MODE_IKEMEN: 1.0 / 30.0}
MARGIN_FACTOR_STATIC = {MODE_MUGEN: 0.1, MODE_IKEMEN: 1.0 / 30.0}
COMMENT_RE = re.compile(r";.*")
SECTION_RE = re.compile(r"^\[([^]]+)]$")
PARAMETER_RE = re.compile(r"^([^=]+)=([^=]*)$")
LINE_BREAK_RE = re.compile("[\r\n]+$")
FIRST_VALUE_RE = re.compile(r"(^\s*[^=;]+\s*=\s*)([^=,;\s]+)(.*$)")
SECOND_VALUE_RE = re.compile(r"(^\s*[^=;]+\s*=\s*[^=,;\s]+\s*,\s*)([^=,;\s]+)(.*$)")
@dataclass(frozen=True)
class Parameter:
name: str
values: list[str]
@dataclass(frozen=True)
class LocalCoord:
width: float = 320.0
height: float = 240.0
@dataclass(frozen=True)
class ParallaxInfo:
bg: str
xscale_index: int
Parser = Callable[[str], float]
Modifier = Callable[[float], float]
Verifier = Callable[[float, float], None]
def parse_ratio(ratio: str) -> float:
match = re.search(r"^\s*([0-9]+)[^0-9]([0-9]+)\s*$", ratio)
if match is None:
CMD_PARSER.error(f"invalid aspect ratio definition: '{match}', "
f"use e.g. '16x9', '21x9' or '16:9', '21:9' etc.")
width = int(match.group(1))
height = int(match.group(2))
return float(width) / height
def parse_parameter(param_line: str, stage_name: str) -> Optional[Parameter]:
match = PARAMETER_RE.search(param_line)
if match is None:
return None
param_name = match.group(1).strip().casefold()
param_values = match.group(2).strip().split(",")
if param_name in VALUES_COUNT:
actual_len = len(param_values)
expected_len = VALUES_COUNT[param_name]
if actual_len not in (expected_len if isinstance(expected_len, set) else {expected_len}):
CMD_PARSER.error(f"invalid parameter for stage '{stage_name}', line: '{param_line}'")
return Parameter(param_name, list(value.strip() for value in param_values))
def parse_value(param_line: str, pattern: Pattern, name: str) -> float:
try:
match = pattern.search(param_line)
return float(match.group(2))
except:
CMD_PARSER.error(f"failed to parse the {name} parameter value, line: '{param_line}'")
def parse_first_value(param_line: str) -> float:
return parse_value(param_line, FIRST_VALUE_RE, "first")
def parse_second_value(param_line: str) -> float:
return parse_value(param_line, SECOND_VALUE_RE, "second")
def round_value(value: float) -> Decimal:
precision
= DECIMAL_PLACES
+ (0 if abs(value
) < 1 else int(floor(log10(abs(value
)) + 1.0))) return Decimal(value).normalize(Context(prec=precision, rounding=ROUND_HALF_DOWN))
def modify_value(lines: list[str], index: int, pattern: Pattern,
parse: Parser, modify: Modifier, verify: Optional[Verifier] = None) -> None:
param_line = lines[index]
old_value = parse(param_line)
new_value = modify(old_value)
not verify or verify(old_value, new_value)
rounded_value = round_value(new_value)
lines[index] = pattern.sub(f"\\g<1>{rounded_value:f}\\g<3>", param_line)
def modify_first_value(lines: list[str], index: int, modify: Modifier, verify: Optional[Verifier] = None) -> None:
return modify_value(lines, index, FIRST_VALUE_RE, parse_first_value, modify, verify)
def modify_second_value(lines: list[str], index: int, modify: Modifier, verify: Optional[Verifier] = None) -> None:
return modify_value(lines, index, SECOND_VALUE_RE, parse_second_value, modify, verify)
def set_value(lines: list[str], index: int, value: float) -> None:
param_line = lines[index]
lines[index] = FIRST_VALUE_RE.sub(f"\\g<1>{round_value(value):f}\\g<3>", param_line)
def insert_value(lines: list[tuple[int, str]], index: int, param_name: str, value: float, line_break_str: str) -> None:
lines.append((index + 1, f"{param_name} = {round_value(value):f}{line_break_str}"))
def set_or_insert_value(
set_lines: list[str], set_index: int, insert_lines: list[tuple[int, str]], insert_index: int,
param_name: str, value: float, line_break_str: str) -> None:
if set_index is not None:
set_value(set_lines, set_index, value)
else:
insert_value(insert_lines, insert_index, param_name, value, line_break_str)
def get_width_extension_ratio(zoom: float) -> float:
return ((1.0 / zoom) - 1.0) / 2.0
def get_height_margin(bounds_mode: str, full_height: float, bound_high: float) -> Optional[float]:
factor = MARGIN_FACTOR if bound_high else MARGIN_FACTOR_STATIC
return full_height * factor[bounds_mode] if factor[bounds_mode] else None
def debug(a: float, b: [float, None] = None) -> str:
return f"({round_value(a)})" if b is None else f"({round_value(a)}, {round_value(b)})"
def process_stage(args, stage_file: Path, target_ratio: float, bounds_mode: str) -> list[str]:
with open(stage_file, newline="", encoding="iso8859_1") as stage_input:
lines = stage_input.readlines()
stage_name = stage_file.name
values: dict[str, Any] = {LOCAL_COORD: LocalCoord(), BOUND_HIGH: -25.0, WINDOW: []}
indexes: dict[str, Any] = {DELTA: [], WINDOW: []}
parallax_infos: list[ParallaxInfo] = []
parallax_delta_indexes: dict[str, int] = {}
current_section = None
current_bg_type = None
for line_index, line in enumerate(lines):
clean_line = COMMENT_RE.sub("", line).strip()
if not clean_line:
continue
section_match = SECTION_RE.search(clean_line)
if section_match is not None:
current_section = section_match.group(1).strip().casefold()
indexes[current_section] = line_index
current_bg_type = None
continue
if current_section is None:
continue
parameter = parse_parameter(clean_line, stage_name)
if parameter is None:
continue
if parameter.name == TYPE:
current_bg_type = parameter.values[0].strip().casefold()
continue
elif parameter.name == DELTA:
delta_value = float(parameter.values[0])
if delta_value < 0.0:
CMD_PARSER.error(f"for stage '{stage_name}', "
f"negative delta values {debug(delta_value)} are not supported, aborting'")
if current_bg_type == PARALLAX:
parallax_delta_indexes[current_section] = line_index
else:
indexes[DELTA].append(line_index)
continue
elif parameter.name == XSCALE and current_bg_type == PARALLAX:
parallax_infos.append(ParallaxInfo(current_section, line_index))
continue
elif parameter.name == WINDOW:
indexes[WINDOW].append(line_index)
values[WINDOW].append(list(float(coord) for coord in parameter.values))
continue
if current_section == INFO:
if parameter.name == MUGEN_VERSION:
indexes[MUGEN_VERSION] = line_index
elif current_section == STAGE_INFO:
if parameter.name == LOCAL_COORD:
values[LOCAL_COORD] = LocalCoord(float(parameter.values[0]), float(parameter.values[1]))
elif parameter.name == HIRES:
CMD_PARSER.error(f"the 'hires' parameter is not supported for stage '{stage_name}'")
elif current_section == PLAYER_INFO:
if parameter.name in {P1_START_X, P2_START_X}:
indexes[parameter.name] = line_index
elif current_section == CAMERA:
if parameter.name in {ZOOM_IN, ZOOM_OUT}:
indexes[parameter.name] = line_index
zoom_value = parse_first_value(clean_line)
if zoom_value != 1.0:
CMD_PARSER.error(f"only zoom-less stages are supported, "
f"but stage '{stage_name}' has zoom values other than 1.0")
elif parameter.name in {BOUND_HIGH, BOUND_LEFT, BOUND_RIGHT, CUT_LOW, VERTICAL_FOLLOW}:
indexes[parameter.name], values[parameter.name] = line_index, float(parameter.values[0])
elif current_section == BG_DEF:
if parameter.name == DEBUG_BG:
indexes[DEBUG_BG] = line_index
if not (BOUND_LEFT in indexes and BOUND_RIGHT in indexes):
CMD_PARSER.error(f"camera bound parameters missing for stage '{stage_name}'")
local_width = values[LOCAL_COORD].width
local_height = values[LOCAL_COORD].height
for window_index, line_index in enumerate(indexes[WINDOW]):
window_coords = values[WINDOW][window_index]
window_width = window_coords[2] - window_coords[0]
window_height = window_coords[3] - window_coords[1]
# detect and remove full-screen windows
if local_width - window_width <= WINDOW_TOLERANCE and local_height - window_height <= WINDOW_TOLERANCE:
lines[line_index] = ";" + lines[line_index]
local_ratio = local_width / local_height
target_zoom = args.target_zoom or 1.0 / (target_ratio / local_ratio)
width_extension = local_width * get_width_extension_ratio(target_zoom)
modify_first_value(lines, indexes[BOUND_LEFT], lambda bound: bound + width_extension)
modify_first_value(lines, indexes[BOUND_RIGHT], lambda bound: bound - width_extension)
bound_radius = (values[BOUND_RIGHT] - values[BOUND_LEFT]) / 2.0
def delta_modifier(delta: float) -> float:
return max((delta * bound_radius - width_extension) / (bound_radius - width_extension), 0.0)
def delta_verifier(old_delta: float, new_delta: float):
if old_delta > 0.0 and new_delta == 0.0:
print(f"{CMD_PARSER.prog}: warning: for stage '{stage_name}', "
f"capping a non-zero delta {debug(old_delta)} to zero; "
f"this suggests that some artifacts may remain", file=sys.stderr)
for delta_index in indexes[DELTA]:
modify_first_value(lines, delta_index, delta_modifier, delta_verifier)
for parallax_info in parallax_infos if not args.no_parallax else []:
xscale_index = parallax_info.xscale_index
top_xscale = parse_first_value(lines[xscale_index])
bottom_xscale = parse_second_value(lines[xscale_index])
if isclose(top_xscale, 0.0) or isclose(bottom_xscale, 0.0):
print(f"{CMD_PARSER.prog}: warning: for stage '{stage_name}', will not modify parallax "
f"with xscale values {debug(top_xscale, bottom_xscale)} too close to zero")
continue
bottom_delta_factor = bottom_xscale / top_xscale
delta_index = parallax_delta_indexes[parallax_info.bg]
top_delta = parse_first_value(lines[delta_index])
bottom_delta = top_delta * bottom_delta_factor
if isclose(top_delta, 0.0) or isclose(bottom_delta, 0.0):
print(f"{CMD_PARSER.prog}: warning: for stage '{stage_name}', will not modify parallax "
f"with effective delta values {debug(top_delta, bottom_delta)} too close to zero")
continue
if isclose(bottom_delta_factor, 1.0) or isclose(bottom_delta, 1.0):
modify_first_value(lines, delta_index, delta_modifier, delta_verifier)
continue
if isclose(top_delta, 1.0):
new_bottom_delta = delta_modifier(bottom_delta_factor)
if isclose(new_bottom_delta, 0.0):
print(f"{CMD_PARSER.prog}: warning: for stage '{stage_name}', will not modify parallax "
f"with resulting bottom xscale value {debug(new_bottom_delta)} too close to zero")
continue
modify_first_value(lines, xscale_index, lambda _: 1.0)
modify_second_value(lines, xscale_index, lambda _: new_bottom_delta)
continue
if top_delta < bottom_delta:
new_top_delta = delta_modifier(top_delta)
new_bottom_delta = (bottom_delta - 1.0) * (1.0 - new_top_delta) / (1.0 - top_delta) + 1.0
else:
new_bottom_delta = delta_modifier(bottom_delta)
new_top_delta = (top_delta - 1.0) * (1.0 - new_bottom_delta) / (1.0 - bottom_delta) + 1.0
if isclose(new_top_delta, 0.0) or isclose(new_bottom_delta, 0.0):
print(f"{CMD_PARSER.prog}: warning: for stage '{stage_name}', will not modify parallax "
f"with resulting effective delta values {debug(new_top_delta, new_bottom_delta)} too close to zero")
continue
modify_first_value(lines, delta_index, lambda _: new_top_delta)
modify_first_value(lines, xscale_index, lambda _: 1.0)
modify_second_value(lines, xscale_index, lambda _: new_bottom_delta / new_top_delta)
zoom_in = args.set_zoom_in or target_zoom
zoom_out = args.set_zoom_out or target_zoom
if zoom_out < target_zoom:
print(f"{CMD_PARSER.prog}: warning: for stage '{stage_name}', setting the zoom-out ({zoom_out}) "
f"lower than the target zoom ({target_zoom}) will introduce artifacts", file=sys.stderr)
start_x_ratio = get_width_extension_ratio(zoom_in) + 1.0
for start_index in [indexes[P1_START_X], indexes[P2_START_X]]:
modify_first_value(lines, start_index, lambda start_x: start_x * start_x_ratio)
if values[VERTICAL_FOLLOW] == 0:
assert VERTICAL_FOLLOW in indexes
, "'verticalfollow' must exist in the DEF file to be non-zero" set_value(lines, indexes[VERTICAL_FOLLOW], VERTICAL_FOLLOW_MIN)
line_break = LINE_BREAK_RE.search(lines[indexes[INFO]]).group(0)
insert_lines = []
def update(param: str, insert_index: int, value: float) -> None:
set_or_insert_value(lines, indexes.get(param), insert_lines, insert_index, param.lower(), value, line_break)
if args.debug_bg:
update(DEBUG_BG, indexes[BG_DEF], 1)
camera_index = indexes[CAMERA]
if not bounds_mode == MODE_NONE:
is_static = values[BOUND_HIGH] == 0.0
height_margin = round(get_height_margin(bounds_mode, local_height, values[BOUND_HIGH]))
if bounds_mode == MODE_MUGEN or (bounds_mode == MODE_IKEMEN and not is_static):
update(BOUND_HIGH, camera_index, values[BOUND_HIGH] + height_margin)
update(CUT_LOW, camera_index, height_margin)
else: # bounds_mode == MODE_IKEMEN and is_static:
update(BOUND_HIGH, camera_index, 1)
update(CUT_LOW, camera_index, height_margin + 1)
update(VERTICAL_FOLLOW, camera_index, VERTICAL_FOLLOW_MIN)
update(ZOOM_OUT, camera_index, zoom_out)
update(ZOOM_IN, camera_index, zoom_in)
update(MUGEN_VERSION, indexes[INFO], 1.1)
for insert_line in sorted(insert_lines, key = lambda it: it[0], reverse = True):
lines.insert(insert_line[0], insert_line[1])
return lines
def get_stage_out_file(stage_file: Path, out_path: Path) -> Path:
if not out_path:
out_file = stage_file
elif out_path.is_dir():
out_file = Path(out_path, stage_file.name)
else:
out_file = out_path
if out_file.exists() and not out_file.is_file():
CMD_PARSER.error(f"invalid output file: '{out_file}'")
return out_file
def main():
args = CMD_PARSER.parse_args()
if len(args.stage_def_files) > 1 and args.output_path and not args.output_path.is_dir():
CMD_PARSER.error(f"the output path needs to be a directory "
f"if multiple input file are given: '{args.output_path}'")
if args.target_aspect_ratio and args.target_zoom:
CMD_PARSER.error(f"the target aspect ratio (-r) and target zoom (-z) options are mutually exclusive")
bounds_mode = (args.bounds_mode or MODE_MUGEN).casefold()
if bounds_mode not in BOUNDS_MODE_VALUES:
CMD_PARSER.error(f"invalid bounds mode: '{bounds_mode}', allowed values: {BOUNDS_MODE_VALUES}")
target_ratio = parse_ratio(args.target_aspect_ratio or "16x9")
for stage_file in args.stage_def_files:
if not stage_file.is_file():
CMD_PARSER.error(f"the given stage DEF_FILE path: '{stage_file}' does not point to a regular file")
stage_out_file = get_stage_out_file(stage_file, args.output_path)
stage_bak_file = stage_out_file.with_name(stage_out_file.name + ".bak")
make_backup = stage_out_file.exists() and not args.no_backup
if make_backup and stage_bak_file.exists():
CMD_PARSER.error(f"the stage backup file: '{stage_bak_file}' already exists, aborting")
lines = process_stage(args, stage_file, target_ratio, bounds_mode)
if make_backup:
shutil.copyfile(stage_out_file, stage_bak_file)
stage_out_file.parent.mkdir(parents=True, exist_ok=True)
with open(stage_out_file, "w", newline="", encoding="iso8859_1") as stage_output:
stage_output.writelines(lines)
if __name__ == '__main__':
main()