#!/usr/bin/python3
# Copyright (c) 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.
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
CMD_PARSER = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter, description="""
A stage DEF file converter for M.U.G.E.N/Ikemen that allows to resize the whole stage by using
the `xscale` and `yscale` parameters. The horizontal deltas, vertical offsets of backgrounds
and the left, right and upper camera bounds are also adjusted accordingly.
The vertical camera bounds may still need manual adjustments after conversion.
Stages that already have the `xscale` or `yscale` parameters set to values other than 1.0
are not supported. Highres stages using the `highres` parameter are also not supported.
""", epilog="""
examples:
%(prog)s -s 1.5 DEF_FILE
modify the given stage DEF file to resize the stage to 150%%
%(prog)s -y 1.25 DEF_FILE
modify the given stage DEF file to resize the stage vertically to 125%%
the horizontal dimensions of the stage will remain unchanged
Copyright (c) 2024 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("-s", "--scale", metavar="SCALE", type=float, help="""
use the `xscale` and `yscale` stage parameters to resize the stage by the given factor
adjust the horizontal deltas, vertical offsets of backgrounds and the camera bounds
""".strip())
CMD_PARSER.add_argument("-x", "--xscale", metavar="XSCALE", type=float, help=f"""
use the `xscale` stage parameter to resize the stage horizontally by the given factor
adjust the horizontal deltas of backgrounds and the left and right camera bounds
""".strip())
CMD_PARSER.add_argument("-y", "--yscale", metavar="YSCALE", type=float, help=f"""
use the `yscale` stage parameter to resize the stage vertically by the given factor
adjust the vertical offsets of backgrounds and the upper camera bound
""".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.0.0")
INFO = "Info".casefold()
STAGE_INFO = "StageInfo".casefold()
CAMERA = "Camera".casefold()
BG_DEF = "BGdef".casefold()
MUGEN_VERSION = "mugenversion".casefold()
LOCAL_COORD = "localcoord".casefold()
Z_OFFSET = "zoffset".casefold()
HIRES = "hires".casefold()
X_SCALE = "xscale".casefold()
Y_SCALE = "yscale".casefold()
BOUND_HIGH = "boundhigh".casefold()
BOUND_LEFT = "boundleft".casefold()
BOUND_RIGHT = "boundright".casefold()
TYPE = "type".casefold()
START = "start".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,
X_SCALE: {1, 2}, Y_SCALE: 1,
BOUND_HIGH: 1, BOUND_LEFT: 1, BOUND_RIGHT: 1,
START: 2, DELTA: {1, 2}, WINDOW: 4,
BG_DEF: 1, TYPE: 1
}
DECIMAL_PLACES = 8
WINDOW_TOLERANCE = 5
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_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 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, x_scale: float, y_scale: float) -> 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] = {START: [], 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 == START:
indexes[START].append(line_index)
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 == Z_OFFSET:
indexes[parameter.name], values[parameter.name] = line_index, float(parameter.values[0])
elif parameter.name == HIRES:
CMD_PARSER.error(f"the 'hires' parameter is not supported for stage '{stage_name}'")
elif parameter.name in {X_SCALE, Y_SCALE}:
indexes[parameter.name] = line_index
scale_value = float(parameter.values[0])
if scale_value != 1.0:
if (parameter.name == X_SCALE and x_scale != 1.0) or (parameter.name == Y_SCALE and y_scale != 1.0):
CMD_PARSER.error(f"re-scaling already scaled stages is not supported, but stage '{stage_name}' "
f"has '{parameter.name.lower()}' value other than 1.0")
elif current_section == CAMERA:
if parameter.name in {BOUND_HIGH, BOUND_LEFT, BOUND_RIGHT}:
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]
bounds_diameter = values[BOUND_RIGHT] - values[BOUND_LEFT]
stage_diameter = bounds_diameter + local_width
bounds_radius = bounds_diameter / 2.0
bounds_radius_scaled = (stage_diameter * x_scale - local_width) / 2.0
bounds_extension = bounds_radius_scaled - bounds_radius
modify_first_value(lines, indexes[BOUND_LEFT], lambda bound: bound - bounds_extension)
modify_first_value(lines, indexes[BOUND_RIGHT], lambda bound: bound + bounds_extension)
def delta_modifier(delta: float) -> float:
bg_diameter = bounds_diameter * delta + local_width
return max((bg_diameter * x_scale - local_width) / (2.0 * bounds_radius_scaled), 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)
height_extension = local_height * (y_scale - 1.0)
y_offset
= -ceil(height_extension
/ y_scale
) if height_extension
> 0.0 else -floor(height_extension
/ y_scale
)
for start_index in indexes[START]:
modify_second_value(lines, start_index, lambda start_y: start_y + y_offset)
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)
z_complement = local_height - values[Z_OFFSET]
z_reduction = values[Z_OFFSET] - round(local_height - z_complement * y_scale)
modify_first_value(lines, indexes[Z_OFFSET], lambda z_offset: z_offset - z_reduction)
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)
update(Y_SCALE, indexes[STAGE_INFO], y_scale)
update(X_SCALE, indexes[STAGE_INFO], x_scale)
update
(BOUND_HIGH
, indexes
[CAMERA
], min
(floor((values
[BOUND_HIGH
] + y_offset
) * y_scale
), 0))
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 not (args.scale or args.xscale or args.yscale):
CMD_PARSER.error(f"unspecified scale: please specify using -s, -x or -y")
x_scale = args.xscale or args.scale or 1.0
y_scale = args.yscale or args.scale or 1.0
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, x_scale, y_scale)
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()