fork download
  1. #!/usr/bin/python3
  2.  
  3. # Copyright (c) 2024 Pixel Grass
  4. #
  5. # Permission is hereby granted, free of charge, to any person obtaining a copy
  6. # of this software and associated documentation files (the "Software"), to deal
  7. # in the Software without restriction, including without limitation the rights
  8. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9. # copies of the Software, and to permit persons to whom the Software is
  10. # furnished to do so, subject to the following conditions:
  11. #
  12. # The above copyright notice and this permission notice shall be included in all
  13. # copies or substantial portions of the Software.
  14. #
  15. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  16. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  17. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  18. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  19. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  20. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  21. # SOFTWARE.
  22.  
  23.  
  24. import argparse
  25. import re
  26. import shutil
  27. import sys
  28.  
  29. from dataclasses import dataclass
  30. from decimal import Decimal, Context, ROUND_HALF_DOWN
  31. from math import ceil, floor, log10, isclose
  32. from pathlib import Path
  33. from typing import Any, Callable, Optional, Pattern
  34.  
  35. CMD_PARSER = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter, description="""
  36. A stage DEF file converter for M.U.G.E.N/Ikemen that allows to resize the whole stage by using
  37. the `xscale` and `yscale` parameters. The horizontal deltas, vertical offsets of backgrounds
  38. and the left, right and upper camera bounds are also adjusted accordingly.
  39.  
  40. The vertical camera bounds may still need manual adjustments after conversion.
  41.  
  42. Stages that already have the `xscale` or `yscale` parameters set to values other than 1.0
  43. are not supported. Highres stages using the `highres` parameter are also not supported.
  44. """, epilog="""
  45. examples:
  46. %(prog)s -s 1.5 DEF_FILE
  47. modify the given stage DEF file to resize the stage to 150%%
  48. %(prog)s -y 1.25 DEF_FILE
  49. modify the given stage DEF file to resize the stage vertically to 125%%
  50. the horizontal dimensions of the stage will remain unchanged
  51.  
  52. Copyright (c) 2024 Pixel Grass, License: MIT
  53. ** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. **
  54. """)
  55.  
  56. CMD_PARSER.add_argument("stage_def_files", metavar="DEF_FILE", type=Path, nargs="+", help="""
  57. the stage DEF file (multiple files can be given)\nthe files will be modified in-place!
  58. """.strip())
  59.  
  60. CMD_PARSER.add_argument("-s", "--scale", metavar="SCALE", type=float, help="""
  61. use the `xscale` and `yscale` stage parameters to resize the stage by the given factor
  62. adjust the horizontal deltas, vertical offsets of backgrounds and the camera bounds
  63. """.strip())
  64.  
  65. CMD_PARSER.add_argument("-x", "--xscale", metavar="XSCALE", type=float, help=f"""
  66. use the `xscale` stage parameter to resize the stage horizontally by the given factor
  67. adjust the horizontal deltas of backgrounds and the left and right camera bounds
  68. """.strip())
  69.  
  70. CMD_PARSER.add_argument("-y", "--yscale", metavar="YSCALE", type=float, help=f"""
  71. use the `yscale` stage parameter to resize the stage vertically by the given factor
  72. adjust the vertical offsets of backgrounds and the upper camera bound
  73. """.strip())
  74.  
  75. CMD_PARSER.add_argument("-p", "--no-parallax", default=False, action='store_true', help="""
  76. do not modify the parallax backgrounds (default: modify parallax backgrounds)
  77. """.strip())
  78.  
  79. CMD_PARSER.add_argument("-d", "--debug-bg", default=False, action='store_true', help="""
  80. set the `debugbg` property to `1` in the modified stage DEF file (default: do not set)
  81. """.strip())
  82.  
  83. CMD_PARSER.add_argument("-o", "--output-path", metavar="OUT_PATH", type=Path, help="""
  84. write the modified stage DEF file or files to the given output file or
  85. directory respectively, instead of modifying the given DEF files in-place
  86. """.strip())
  87.  
  88. CMD_PARSER.add_argument('--no-backup', default=False, action='store_true', help="""
  89. do not create a backup for the modified stage DEF files
  90. all of the file will be irreversibly modified in place!
  91. """.strip())
  92.  
  93. CMD_PARSER.add_argument('--version', action='version', version="%(prog)s 1.0.0")
  94.  
  95.  
  96. INFO = "Info".casefold()
  97. STAGE_INFO = "StageInfo".casefold()
  98. CAMERA = "Camera".casefold()
  99. BG_DEF = "BGdef".casefold()
  100.  
  101. MUGEN_VERSION = "mugenversion".casefold()
  102. LOCAL_COORD = "localcoord".casefold()
  103. Z_OFFSET = "zoffset".casefold()
  104. HIRES = "hires".casefold()
  105. X_SCALE = "xscale".casefold()
  106. Y_SCALE = "yscale".casefold()
  107. BOUND_HIGH = "boundhigh".casefold()
  108. BOUND_LEFT = "boundleft".casefold()
  109. BOUND_RIGHT = "boundright".casefold()
  110. TYPE = "type".casefold()
  111. START = "start".casefold()
  112. DELTA = "delta".casefold()
  113. XSCALE = "xscale".casefold()
  114. WINDOW = "window".casefold()
  115. DEBUG_BG = "debugbg".casefold()
  116.  
  117. PARALLAX = "parallax".casefold()
  118.  
  119.  
  120. VALUES_COUNT = {
  121. MUGEN_VERSION: 1,
  122. LOCAL_COORD: 2, HIRES: 1,
  123. X_SCALE: {1, 2}, Y_SCALE: 1,
  124. BOUND_HIGH: 1, BOUND_LEFT: 1, BOUND_RIGHT: 1,
  125. START: 2, DELTA: {1, 2}, WINDOW: 4,
  126. BG_DEF: 1, TYPE: 1
  127. }
  128.  
  129. DECIMAL_PLACES = 8
  130. WINDOW_TOLERANCE = 5
  131.  
  132. COMMENT_RE = re.compile(r";.*")
  133. SECTION_RE = re.compile(r"^\[([^]]+)]$")
  134. PARAMETER_RE = re.compile(r"^([^=]+)=([^=]*)$")
  135. LINE_BREAK_RE = re.compile("[\r\n]+$")
  136.  
  137. FIRST_VALUE_RE = re.compile(r"(^\s*[^=;]+\s*=\s*)([^=,;\s]+)(.*$)")
  138. SECOND_VALUE_RE = re.compile(r"(^\s*[^=;]+\s*=\s*[^=,;\s]+\s*,\s*)([^=,;\s]+)(.*$)")
  139.  
  140.  
  141. @dataclass(frozen=True)
  142. class Parameter:
  143. name: str
  144. values: list[str]
  145.  
  146.  
  147. @dataclass(frozen=True)
  148. class LocalCoord:
  149. width: float = 320.0
  150. height: float = 240.0
  151.  
  152.  
  153. @dataclass(frozen=True)
  154. class ParallaxInfo:
  155. bg: str
  156. xscale_index: int
  157.  
  158.  
  159. Parser = Callable[[str], float]
  160. Modifier = Callable[[float], float]
  161. Verifier = Callable[[float, float], None]
  162.  
  163.  
  164. def parse_parameter(param_line: str, stage_name: str) -> Optional[Parameter]:
  165. match = PARAMETER_RE.search(param_line)
  166. if match is None:
  167. return None
  168.  
  169. param_name = match.group(1).strip().casefold()
  170. param_values = match.group(2).strip().split(",")
  171.  
  172. if param_name in VALUES_COUNT:
  173. actual_len = len(param_values)
  174. expected_len = VALUES_COUNT[param_name]
  175.  
  176. if actual_len not in (expected_len if isinstance(expected_len, set) else {expected_len}):
  177. CMD_PARSER.error(f"invalid parameter for stage '{stage_name}', line: '{param_line}'")
  178.  
  179. return Parameter(param_name, list(value.strip() for value in param_values))
  180.  
  181.  
  182. def parse_value(param_line: str, pattern: Pattern, name: str) -> float:
  183. try:
  184. match = pattern.search(param_line)
  185. return float(match.group(2))
  186. except:
  187. CMD_PARSER.error(f"failed to parse the {name} parameter value, line: '{param_line}'")
  188.  
  189.  
  190. def parse_first_value(param_line: str) -> float:
  191. return parse_value(param_line, FIRST_VALUE_RE, "first")
  192.  
  193.  
  194. def parse_second_value(param_line: str) -> float:
  195. return parse_value(param_line, SECOND_VALUE_RE, "second")
  196.  
  197.  
  198. def round_value(value: float) -> Decimal:
  199. precision = DECIMAL_PLACES + (0 if abs(value) < 1 else int(floor(log10(abs(value)) + 1.0)))
  200. return Decimal(value).normalize(Context(prec=precision, rounding=ROUND_HALF_DOWN))
  201.  
  202.  
  203. def modify_value(lines: list[str], index: int, pattern: Pattern,
  204. parse: Parser, modify: Modifier, verify: Optional[Verifier] = None) -> None:
  205. param_line = lines[index]
  206.  
  207. old_value = parse(param_line)
  208. new_value = modify(old_value)
  209. not verify or verify(old_value, new_value)
  210.  
  211. rounded_value = round_value(new_value)
  212. lines[index] = pattern.sub(f"\\g<1>{rounded_value:f}\\g<3>", param_line)
  213.  
  214.  
  215. def modify_first_value(lines: list[str], index: int, modify: Modifier, verify: Optional[Verifier] = None) -> None:
  216. return modify_value(lines, index, FIRST_VALUE_RE, parse_first_value, modify, verify)
  217.  
  218.  
  219. def modify_second_value(lines: list[str], index: int, modify: Modifier, verify: Optional[Verifier] = None) -> None:
  220. return modify_value(lines, index, SECOND_VALUE_RE, parse_second_value, modify, verify)
  221.  
  222.  
  223. def set_value(lines: list[str], index: int, value: float) -> None:
  224. param_line = lines[index]
  225. lines[index] = FIRST_VALUE_RE.sub(f"\\g<1>{round_value(value):f}\\g<3>", param_line)
  226.  
  227.  
  228. def insert_value(lines: list[tuple[int, str]], index: int, param_name: str, value: float, line_break_str: str) -> None:
  229. lines.append((index + 1, f"{param_name} = {round_value(value):f}{line_break_str}"))
  230.  
  231.  
  232. def set_or_insert_value(
  233. set_lines: list[str], set_index: int, insert_lines: list[tuple[int, str]], insert_index: int,
  234. param_name: str, value: float, line_break_str: str) -> None:
  235. if set_index is not None:
  236. set_value(set_lines, set_index, value)
  237. else:
  238. insert_value(insert_lines, insert_index, param_name, value, line_break_str)
  239.  
  240.  
  241. def debug(a: float, b: [float, None] = None) -> str:
  242. return f"({round_value(a)})" if b is None else f"({round_value(a)}, {round_value(b)})"
  243.  
  244.  
  245. def process_stage(args, stage_file: Path, x_scale: float, y_scale: float) -> list[str]:
  246. with open(stage_file, newline="", encoding="iso8859_1") as stage_input:
  247. lines = stage_input.readlines()
  248.  
  249. stage_name = stage_file.name
  250.  
  251. values: dict[str, Any] = {LOCAL_COORD: LocalCoord(), BOUND_HIGH: -25.0, WINDOW: []}
  252. indexes: dict[str, Any] = {START: [], DELTA: [], WINDOW: []}
  253.  
  254. parallax_infos: list[ParallaxInfo] = []
  255. parallax_delta_indexes: dict[str, int] = {}
  256.  
  257. current_section = None
  258. current_bg_type = None
  259.  
  260. for line_index, line in enumerate(lines):
  261. clean_line = COMMENT_RE.sub("", line).strip()
  262. if not clean_line:
  263. continue
  264.  
  265. section_match = SECTION_RE.search(clean_line)
  266. if section_match is not None:
  267. current_section = section_match.group(1).strip().casefold()
  268. indexes[current_section] = line_index
  269. current_bg_type = None
  270. continue
  271.  
  272. if current_section is None:
  273. continue
  274.  
  275. parameter = parse_parameter(clean_line, stage_name)
  276. if parameter is None:
  277. continue
  278.  
  279. if parameter.name == TYPE:
  280. current_bg_type = parameter.values[0].strip().casefold()
  281. continue
  282.  
  283. elif parameter.name == START:
  284. indexes[START].append(line_index)
  285. continue
  286.  
  287. elif parameter.name == DELTA:
  288. delta_value = float(parameter.values[0])
  289. if delta_value < 0.0:
  290. CMD_PARSER.error(f"for stage '{stage_name}', "
  291. f"negative delta values {debug(delta_value)} are not supported, aborting'")
  292.  
  293. if current_bg_type == PARALLAX:
  294. parallax_delta_indexes[current_section] = line_index
  295. else:
  296. indexes[DELTA].append(line_index)
  297. continue
  298.  
  299. elif parameter.name == XSCALE and current_bg_type == PARALLAX:
  300. parallax_infos.append(ParallaxInfo(current_section, line_index))
  301. continue
  302.  
  303. elif parameter.name == WINDOW:
  304. indexes[WINDOW].append(line_index)
  305. values[WINDOW].append(list(float(coord) for coord in parameter.values))
  306. continue
  307.  
  308. if current_section == INFO:
  309. if parameter.name == MUGEN_VERSION:
  310. indexes[MUGEN_VERSION] = line_index
  311.  
  312. elif current_section == STAGE_INFO:
  313. if parameter.name == LOCAL_COORD:
  314. values[LOCAL_COORD] = LocalCoord(float(parameter.values[0]), float(parameter.values[1]))
  315.  
  316. elif parameter.name == Z_OFFSET:
  317. indexes[parameter.name], values[parameter.name] = line_index, float(parameter.values[0])
  318.  
  319. elif parameter.name == HIRES:
  320. CMD_PARSER.error(f"the 'hires' parameter is not supported for stage '{stage_name}'")
  321.  
  322. elif parameter.name in {X_SCALE, Y_SCALE}:
  323. indexes[parameter.name] = line_index
  324.  
  325. scale_value = float(parameter.values[0])
  326. if scale_value != 1.0:
  327. if (parameter.name == X_SCALE and x_scale != 1.0) or (parameter.name == Y_SCALE and y_scale != 1.0):
  328. CMD_PARSER.error(f"re-scaling already scaled stages is not supported, but stage '{stage_name}' "
  329. f"has '{parameter.name.lower()}' value other than 1.0")
  330.  
  331. elif current_section == CAMERA:
  332. if parameter.name in {BOUND_HIGH, BOUND_LEFT, BOUND_RIGHT}:
  333. indexes[parameter.name], values[parameter.name] = line_index, float(parameter.values[0])
  334.  
  335. elif current_section == BG_DEF:
  336. if parameter.name == DEBUG_BG:
  337. indexes[DEBUG_BG] = line_index
  338.  
  339. if not (BOUND_LEFT in indexes and BOUND_RIGHT in indexes):
  340. CMD_PARSER.error(f"camera bound parameters missing for stage '{stage_name}'")
  341.  
  342. local_width = values[LOCAL_COORD].width
  343. local_height = values[LOCAL_COORD].height
  344.  
  345. for window_index, line_index in enumerate(indexes[WINDOW]):
  346. window_coords = values[WINDOW][window_index]
  347. window_width = window_coords[2] - window_coords[0]
  348. window_height = window_coords[3] - window_coords[1]
  349.  
  350. # detect and remove full-screen windows
  351. if local_width - window_width <= WINDOW_TOLERANCE and local_height - window_height <= WINDOW_TOLERANCE:
  352. lines[line_index] = ";" + lines[line_index]
  353.  
  354. bounds_diameter = values[BOUND_RIGHT] - values[BOUND_LEFT]
  355. stage_diameter = bounds_diameter + local_width
  356.  
  357. bounds_radius = bounds_diameter / 2.0
  358. bounds_radius_scaled = (stage_diameter * x_scale - local_width) / 2.0
  359. bounds_extension = bounds_radius_scaled - bounds_radius
  360.  
  361. modify_first_value(lines, indexes[BOUND_LEFT], lambda bound: bound - bounds_extension)
  362. modify_first_value(lines, indexes[BOUND_RIGHT], lambda bound: bound + bounds_extension)
  363.  
  364. def delta_modifier(delta: float) -> float:
  365. bg_diameter = bounds_diameter * delta + local_width
  366. return max((bg_diameter * x_scale - local_width) / (2.0 * bounds_radius_scaled), 0.0)
  367.  
  368. def delta_verifier(old_delta: float, new_delta: float):
  369. if old_delta > 0.0 and new_delta == 0.0:
  370. print(f"{CMD_PARSER.prog}: warning: for stage '{stage_name}', "
  371. f"capping a non-zero delta {debug(old_delta)} to zero; "
  372. f"this suggests that some artifacts may remain", file=sys.stderr)
  373.  
  374. for delta_index in indexes[DELTA]:
  375. modify_first_value(lines, delta_index, delta_modifier, delta_verifier)
  376.  
  377. height_extension = local_height * (y_scale - 1.0)
  378. y_offset = -ceil(height_extension / y_scale) if height_extension > 0.0 else -floor(height_extension / y_scale)
  379.  
  380. for start_index in indexes[START]:
  381. modify_second_value(lines, start_index, lambda start_y: start_y + y_offset)
  382.  
  383. for parallax_info in parallax_infos if not args.no_parallax else []:
  384. xscale_index = parallax_info.xscale_index
  385. top_xscale = parse_first_value(lines[xscale_index])
  386. bottom_xscale = parse_second_value(lines[xscale_index])
  387.  
  388. if isclose(top_xscale, 0.0) or isclose(bottom_xscale, 0.0):
  389. print(f"{CMD_PARSER.prog}: warning: for stage '{stage_name}', will not modify parallax "
  390. f"with xscale values {debug(top_xscale, bottom_xscale)} too close to zero")
  391. continue
  392.  
  393. bottom_delta_factor = bottom_xscale / top_xscale
  394.  
  395. delta_index = parallax_delta_indexes[parallax_info.bg]
  396. top_delta = parse_first_value(lines[delta_index])
  397. bottom_delta = top_delta * bottom_delta_factor
  398.  
  399. if isclose(top_delta, 0.0) or isclose(bottom_delta, 0.0):
  400. print(f"{CMD_PARSER.prog}: warning: for stage '{stage_name}', will not modify parallax "
  401. f"with effective delta values {debug(top_delta, bottom_delta)} too close to zero")
  402. continue
  403.  
  404. if isclose(bottom_delta_factor, 1.0) or isclose(bottom_delta, 1.0):
  405. modify_first_value(lines, delta_index, delta_modifier, delta_verifier)
  406. continue
  407.  
  408. if isclose(top_delta, 1.0):
  409. new_bottom_delta = delta_modifier(bottom_delta_factor)
  410. if isclose(new_bottom_delta, 0.0):
  411. print(f"{CMD_PARSER.prog}: warning: for stage '{stage_name}', will not modify parallax "
  412. f"with resulting bottom xscale value {debug(new_bottom_delta)} too close to zero")
  413. continue
  414.  
  415. modify_first_value(lines, xscale_index, lambda _: 1.0)
  416. modify_second_value(lines, xscale_index, lambda _: new_bottom_delta)
  417. continue
  418.  
  419. if top_delta < bottom_delta:
  420. new_top_delta = delta_modifier(top_delta)
  421. new_bottom_delta = (bottom_delta - 1.0) * (1.0 - new_top_delta) / (1.0 - top_delta) + 1.0
  422. else:
  423. new_bottom_delta = delta_modifier(bottom_delta)
  424. new_top_delta = (top_delta - 1.0) * (1.0 - new_bottom_delta) / (1.0 - bottom_delta) + 1.0
  425.  
  426. if isclose(new_top_delta, 0.0) or isclose(new_bottom_delta, 0.0):
  427. print(f"{CMD_PARSER.prog}: warning: for stage '{stage_name}', will not modify parallax "
  428. f"with resulting effective delta values {debug(new_top_delta, new_bottom_delta)} too close to zero")
  429. continue
  430.  
  431. modify_first_value(lines, delta_index, lambda _: new_top_delta)
  432.  
  433. modify_first_value(lines, xscale_index, lambda _: 1.0)
  434. modify_second_value(lines, xscale_index, lambda _: new_bottom_delta / new_top_delta)
  435.  
  436. z_complement = local_height - values[Z_OFFSET]
  437. z_reduction = values[Z_OFFSET] - round(local_height - z_complement * y_scale)
  438. modify_first_value(lines, indexes[Z_OFFSET], lambda z_offset: z_offset - z_reduction)
  439.  
  440. line_break = LINE_BREAK_RE.search(lines[indexes[INFO]]).group(0)
  441. insert_lines = []
  442.  
  443. def update(param: str, insert_index: int, value: float) -> None:
  444. set_or_insert_value(lines, indexes.get(param), insert_lines, insert_index, param.lower(), value, line_break)
  445.  
  446. if args.debug_bg:
  447. update(DEBUG_BG, indexes[BG_DEF], 1)
  448.  
  449. update(Y_SCALE, indexes[STAGE_INFO], y_scale)
  450. update(X_SCALE, indexes[STAGE_INFO], x_scale)
  451.  
  452. update(BOUND_HIGH, indexes[CAMERA], min(floor((values[BOUND_HIGH] + y_offset) * y_scale), 0))
  453.  
  454. update(MUGEN_VERSION, indexes[INFO], 1.1)
  455.  
  456. for insert_line in sorted(insert_lines, key = lambda it: it[0], reverse = True):
  457. lines.insert(insert_line[0], insert_line[1])
  458.  
  459. return lines
  460.  
  461.  
  462. def get_stage_out_file(stage_file: Path, out_path: Path) -> Path:
  463. if not out_path:
  464. out_file = stage_file
  465. elif out_path.is_dir():
  466. out_file = Path(out_path, stage_file.name)
  467. else:
  468. out_file = out_path
  469.  
  470. if out_file.exists() and not out_file.is_file():
  471. CMD_PARSER.error(f"invalid output file: '{out_file}'")
  472. return out_file
  473.  
  474.  
  475. def main():
  476. args = CMD_PARSER.parse_args()
  477. if len(args.stage_def_files) > 1 and args.output_path and not args.output_path.is_dir():
  478. CMD_PARSER.error(f"the output path needs to be a directory "
  479. f"if multiple input file are given: '{args.output_path}'")
  480.  
  481. if not (args.scale or args.xscale or args.yscale):
  482. CMD_PARSER.error(f"unspecified scale: please specify using -s, -x or -y")
  483.  
  484. x_scale = args.xscale or args.scale or 1.0
  485. y_scale = args.yscale or args.scale or 1.0
  486.  
  487. for stage_file in args.stage_def_files:
  488. if not stage_file.is_file():
  489. CMD_PARSER.error(f"the given stage DEF_FILE path: '{stage_file}' does not point to a regular file")
  490.  
  491. stage_out_file = get_stage_out_file(stage_file, args.output_path)
  492. stage_bak_file = stage_out_file.with_name(stage_out_file.name + ".bak")
  493.  
  494. make_backup = stage_out_file.exists() and not args.no_backup
  495. if make_backup and stage_bak_file.exists():
  496. CMD_PARSER.error(f"the stage backup file: '{stage_bak_file}' already exists, aborting")
  497.  
  498. lines = process_stage(args, stage_file, x_scale, y_scale)
  499.  
  500. if make_backup:
  501. shutil.copyfile(stage_out_file, stage_bak_file)
  502.  
  503. stage_out_file.parent.mkdir(parents=True, exist_ok=True)
  504.  
  505. with open(stage_out_file, "w", newline="", encoding="iso8859_1") as stage_output:
  506. stage_output.writelines(lines)
  507.  
  508.  
  509. if __name__ == '__main__':
  510. main()
  511.  
Runtime error #stdin #stdout #stderr 0.24s 25136KB
stdin
Standard input is empty
stdout
Standard output is empty
stderr
Traceback (most recent call last):
  File "./prog.py", line 141, in <module>
    @dataclass(frozen=True)
  File "./prog.py", line 144, in Parameter
    values: list[str]
TypeError: 'type' object is not subscriptable