simfile-scripts/make_mines_fake.py
2022-08-20 18:02:40 -07:00

508 lines
16 KiB
Python

R"""
Add a short fake region on every isolated mine
to prevent them from being hit during gameplay.
This script only works on SSC files.
Usage examples:
# Preview changes & detect any errors
python make_mines_fake.py "C:\StepMania\Songs\My Pack\My Song" --dry-run
# Preview changes with errors & warnings suppressed
python make_mines_fake.py "C:\StepMania\Songs\My Pack\My Song" --dry-run --allow-simultaneous --allow-split-timing --ignore-sm
# Commit changes with only warnings suppressed
python make_mines_fake.py "C:\StepMania\Songs\My Pack\My Song" --ignore-sm
"""
import argparse
import bisect
from dataclasses import dataclass, field
from decimal import Decimal
import heapq
import os
import sys
from typing import Iterator, List, NamedTuple, Optional, Union
import simfile
import simfile.dir
from simfile.notes import NoteData, NoteType
from simfile.notes.group import group_notes, SameBeatNotes
from simfile.timing import Beat, BeatValue, BeatValues
####################
# Script arguments #
####################
class MakeMinesFakeArgs:
"""Stores the command-line arguments for this script."""
simfile: str
dry_run: bool
allow_split_timing: bool
allow_simultaneous: bool
ignore_sm: bool
def argparser():
"""Get an ArgumentParser instance for this command-line script."""
parser = argparse.ArgumentParser()
parser.add_argument("simfile", type=str, help="path to the simfile to modify")
parser.add_argument(
"-d",
"--dry-run",
action=argparse.BooleanOptionalAction,
help="preview changes without writing the file",
)
parser.add_argument(
"--allow-split-timing",
action=argparse.BooleanOptionalAction,
help="if necessary, create split timing for charts to avoid interfering with other charts",
)
parser.add_argument(
"--allow-simultaneous",
action=argparse.BooleanOptionalAction,
help="leave mines that occur on the same beat as notes alone",
)
parser.add_argument(
"--ignore-sm",
action=argparse.BooleanOptionalAction,
help="do not warn when an SM file is present alongside the SSC file",
)
return parser
#####################
# Utility functions #
#####################
def whichchart(
ssc: simfile.ssc.SSCSimfile, chart: Union[int, simfile.ssc.SSCChart]
) -> str:
"""
Identify the chart by its difficulty,
as well as its stepstype if multiple are present in the simfile.
Intended for human-readable output.
"""
if isinstance(chart, int):
chartobj_ = ssc.charts[chart]
chartobj: simfile.ssc.SSCChart = chartobj_ # typing workaround
else:
chartobj = chart
stepstypes = set(ch.stepstype for ch in ssc.charts)
if len(stepstypes) > 1:
return f"{chartobj.stepstype} {chartobj.difficulty}"
else:
return chartobj.difficulty or ""
def whichtarget(
ssc: simfile.ssc.SSCSimfile,
target: Union[simfile.ssc.SSCSimfile, simfile.ssc.SSCChart],
) -> str:
"""
Identify the target as a chart (see :func:`whichchart`) or simfile.
Intended for human-readable output.
Returns a string starting with "the ".
"""
if isinstance(target, simfile.ssc.SSCChart):
return f"the {whichchart(ssc, target)} chart"
else:
return "the simfile"
def hastiming(chart: simfile.ssc.SSCChart) -> bool:
"""
Detect whether the chart has its own timing data.
"""
return chart.bpms is not None
def splittiming(ssc: simfile.ssc.SSCSimfile, chart: simfile.ssc.SSCChart) -> None:
"""
Copy timing data from the SSC simfile to its chart.
"""
for td in (
"STOPS",
"DELAYS",
"BPMS",
"WARPS",
"LABELS",
"TIMESIGNATURES",
"TICKCOUNTS",
"COMBOS",
"SPEEDS",
"SCROLLS",
"FAKES",
):
chart[td] = ssc[td]
################
# Script logic #
################
class BeatChartIndex(NamedTuple):
beat: Beat
chart_index: int
class SameBeatMineAndNote(NamedTuple):
beat: Beat
mine_chart_index: int
note_chart_index: int
@dataclass
class NotesAndMines:
"""
Note & mine data across all charts of a simfile.
"""
note_positions: List[BeatChartIndex] = field(default_factory=list)
"""
All of the non-fake, non-mine note object positions in each chart.
Sorted first by beat, then by chart index.
"""
mine_positions: List[BeatChartIndex] = field(default_factory=list)
"""
All of the mine note object positions in each chart.
Sorted first by beat, then chart index.
"""
must_allow_simultaneous: List[SameBeatMineAndNote] = field(default_factory=list)
"""All of the same-beat mine & note pairs within the same chart."""
must_allow_split_timing: List[SameBeatMineAndNote] = field(default_factory=list)
"""All of the same-beat mine & note pairs across different charts."""
class SameBeatMineAndNoteError(Exception):
"""
Raised if a mine & note occur on the same beat
and such an occurrence isn't explicitly allowed by the input arguments.
Stringifies to a multi-line, human-readable error message.
"""
def __init__(
self,
ssc: simfile.ssc.SSCSimfile,
must_allow_simultaneous: List[SameBeatMineAndNote],
must_allow_split_timing: List[SameBeatMineAndNote],
):
self.ssc = ssc
self.must_allow_simultaneous = must_allow_simultaneous
self.must_allow_split_timing = must_allow_split_timing
def _stringify_simultaneous(self, s: SameBeatMineAndNote):
if s.mine_chart_index == s.note_chart_index:
return f"b{s.beat} in {whichchart(self.ssc, s.mine_chart_index)}"
else:
return f"b{s.beat} in {whichchart(self.ssc, s.mine_chart_index)} and {whichchart(self.ssc, s.note_chart_index)}"
def _stringify_simultaneous_list(self, sl: List[SameBeatMineAndNote]):
return "".join(f" {self._stringify_simultaneous(s)}\n" for s in sl)
def __str__(self):
return (
f"ERROR: There are simultaneous mines and notes in {self.ssc.title};\n"
"you will have to either update the chart or pass additional flags to the script\n"
"in order to remedy this error.\n"
+ (
"\n"
"Simultaneous mine & note in the same chart (ignore with --allow-simultaneous):\n"
f"{self._stringify_simultaneous_list(self.must_allow_simultaneous)}"
"Ignoring these occurrences will leave the mines on these beats hittable,\n"
"which may surprise players. Consider relocating these mines instead.\n"
if self.must_allow_simultaneous
else ""
)
+ (
"\n"
"Simultaneous mine & note in different charts (fix with --allow-split-timing):\n"
f"{self._stringify_simultaneous_list(self.must_allow_split_timing)}"
"Note that split timing makes it easy to mess up the timing data if you are\n"
"still making changes to the chart. Use this feature with caution.\n"
if self.must_allow_split_timing
else ""
)
)
def find_chart_with_existing_item(
positions: List[BeatChartIndex],
position: BeatChartIndex,
) -> Optional[int]:
"""
If `positions` has an item on the same beat as `position`,
returns the chart index of the found item.
"""
if not positions:
return None
index = bisect.bisect_left(positions, position.beat, key=lambda pos: pos.beat)
if index == len(positions):
return None
item = positions[index]
if item.beat == position.beat:
return item.chart_index
def append_same_beat_items(
nm: NotesAndMines,
chart_positions: List[BeatChartIndex],
position: BeatChartIndex,
*,
position_is_mine: bool,
) -> None:
"""
If the given position occurs in the other type of items (between notes and mines),
append a SameBeatMineAndNote to the appropriate NotesAndMines `must_allow` list.
"""
items_from_other_charts = (
nm.note_positions if position_is_mine else nm.mine_positions
)
# Look for a note on the same beat in a previously indexed chart
if (
other_chart := find_chart_with_existing_item(items_from_other_charts, position)
) is not None:
mine_chart_index = position.chart_index if position_is_mine else other_chart
note_chart_index = other_chart if position_is_mine else position.chart_index
nm.must_allow_split_timing.append(
SameBeatMineAndNote(
beat=position.beat,
mine_chart_index=mine_chart_index,
note_chart_index=note_chart_index,
)
)
# Look for a note on the same beat in this chart
if find_chart_with_existing_item(chart_positions, position) is not None:
nm.must_allow_simultaneous.append(
SameBeatMineAndNote(
beat=position.beat,
mine_chart_index=position.chart_index,
note_chart_index=position.chart_index,
)
)
def get_notes_and_mines(ssc: simfile.ssc.SSCSimfile) -> NotesAndMines:
"""
Populate & return a NotesAndMines instance for the simfile.
"""
output = NotesAndMines()
for c, chart in enumerate(ssc.charts):
chart_note_positions: List[BeatChartIndex] = []
"""All of the non-fake, non-mine note object positions in this chart. Sorted by beat."""
chart_mine_positions: List[BeatChartIndex] = []
"""All of the mine note object positions in this chart. Sorted by beat."""
nd = NoteData(chart)
for note in nd:
position = BeatChartIndex(beat=note.beat, chart_index=c)
match note.note_type:
case NoteType.MINE:
append_same_beat_items(
output, chart_note_positions, position, position_is_mine=True
)
chart_mine_positions.append(position)
case NoteType.FAKE:
pass
case _: # Any hittable or scoreable "note"
append_same_beat_items(
output, chart_mine_positions, position, position_is_mine=False
)
chart_note_positions.append(position)
output.note_positions = list(
heapq.merge(output.note_positions, chart_note_positions)
)
output.mine_positions = list(
heapq.merge(output.mine_positions, chart_mine_positions)
)
return output
def maybe_raise_simultaneous_error(
ssc: simfile.ssc.SSCSimfile,
args: MakeMinesFakeArgs,
nm: NotesAndMines,
) -> None:
"""
Raise :class:`SameBeatMineAndNoteError`
only if the script arguments don't allow for same-beat mine & note pairs
that were found in the simfile.
"""
simultaneous = [] if args.allow_simultaneous else nm.must_allow_simultaneous
split_timing = [] if args.allow_split_timing else nm.must_allow_split_timing
if simultaneous or split_timing:
raise SameBeatMineAndNoteError(
ssc,
must_allow_simultaneous=simultaneous,
must_allow_split_timing=split_timing,
)
def charts_with_mines(
ssc: simfile.ssc.SSCSimfile, nm: NotesAndMines
) -> Iterator[simfile.ssc.SSCChart]:
"""
Yield only the charts that have mines.
Sorted by chart index.
"""
chart_indexes = set(mine_pos.chart_index for mine_pos in nm.mine_positions)
for chart_index in sorted(chart_indexes):
yield ssc.charts[chart_index]
def make_mines_fake(ssc: simfile.ssc.SSCSimfile, args: MakeMinesFakeArgs) -> List[str]:
"""
Add a short fake segment on each mine in each chart in the simfile,
updating the in-memory simfile object.
Returns a list of actions (human-readable strings)
that were taken on the simfile.
Raises :class:`SameBeatMineAndNoteError`
if a mine & note are found on the same beat
and the arguments don't explicitly allow it.
"""
actions = []
nm = get_notes_and_mines(ssc)
maybe_raise_simultaneous_error(ssc, args, nm)
for chart in charts_with_mines(ssc, nm):
if hastiming(chart):
fakes_target = chart
elif len(nm.must_allow_split_timing) > 0:
actions.append(
f"copy timing data from the simfile to the {whichchart(ssc, chart)} chart"
)
splittiming(ssc, chart)
fakes_target = chart
else:
fakes_target = ssc
fakes = (
BeatValues.from_str(fakes_target.fakes)
if fakes_target.fakes
else BeatValues()
)
for note_group in group_notes(
NoteData(chart),
same_beat_notes=SameBeatNotes.JOIN_ALL,
):
if any(note.note_type == NoteType.MINE for note in note_group):
beat = note_group[0].beat
if not all(note.note_type == NoteType.MINE for note in note_group):
# This case should've been caught already
assert args.allow_simultaneous
continue
already_fake = False
for fake_ in fakes:
fake: BeatValue = fake_ # typing workaround
# Convert the decimal value to string first
# to force Beat() to quantize it to a 192nd
fake_end_beat = fake.beat + Beat(str(fake.value))
# Skip mines that are already inside a fake segment
if fake.beat <= beat < fake_end_beat:
already_fake = True
break
# Stop checking fake segments past the current mine's beat
# Assumption: fake region start & end beats are strictly increasing
if fake.beat > beat:
already_fake = False
break
if already_fake:
continue
actions.append(
f"add a short fake region on b{beat} to {whichtarget(ssc, fakes_target)}"
)
bisect.insort(
fakes,
BeatValue(beat=beat, value=Decimal(str(Beat.tick()))),
key=lambda bv: bv.beat,
)
fakes_target.fakes = str(fakes)
return actions
def main(argv) -> int:
"""
Run the script & return an exit code (0 for success, nonzero for error).
"""
error = False
# Parse command-line arguments
args = argparser().parse_args(argv[1:], namespace=MakeMinesFakeArgs())
if os.path.isdir(args.simfile):
sd = simfile.dir.SimfileDirectory(args.simfile)
if sd.ssc_path:
args.simfile = sd.ssc_path
else:
raise ValueError("simfile directory has no SSC file")
if sd.sm_path and not args.ignore_sm:
print(
"WARNING: there is an SM file in this directory that won't be touched.\n"
"Either delete the SM file or pass --ignore-sm to suppress this warning.\n"
)
input_filename = args.simfile
backup_filename = input_filename + "~"
with simfile.mutate(input_filename, backup_filename=backup_filename) as sf:
if isinstance(sf, simfile.ssc.SSCSimfile):
try:
actions = make_mines_fake(sf, args)
except SameBeatMineAndNoteError as e:
actions = []
error = True
print(str(e))
if actions:
print("Actions taken:\n" + "".join(f" {a}\n" for a in actions))
else:
print("No actions taken")
raise simfile.CancelMutation
else:
raise TypeError("fakes require an SSC file")
if args.dry_run:
print("Not writing changes for dry run")
raise simfile.CancelMutation
else:
print(
f"Writing changes to {input_filename} & backing up original file to {backup_filename}"
)
return 1 if error else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))