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