add make_mines_fake.py & test data
This commit is contained in:
parent
67a3c29f2f
commit
4ac977ccb2
5 changed files with 14034 additions and 0 deletions
508
make_mines_fake.py
Normal file
508
make_mines_fake.py
Normal file
|
@ -0,0 +1,508 @@
|
|||
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))
|
3335
testdata/Coconut/Coconut.sm
vendored
Executable file
3335
testdata/Coconut/Coconut.sm
vendored
Executable file
File diff suppressed because it is too large
Load diff
3483
testdata/Coconut/Coconut.ssc
vendored
Executable file
3483
testdata/Coconut/Coconut.ssc
vendored
Executable file
File diff suppressed because it is too large
Load diff
3335
testdata/original/Coconut/Coconut.sm
vendored
Executable file
3335
testdata/original/Coconut/Coconut.sm
vendored
Executable file
File diff suppressed because it is too large
Load diff
3373
testdata/original/Coconut/Coconut.ssc
vendored
Normal file
3373
testdata/original/Coconut/Coconut.ssc
vendored
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue