mirror of
https://github.com/sndrec/WebMonkeyBall.git
synced 2026-02-03 02:03:33 +00:00
some more work on the custom pack builder
This commit is contained in:
BIN
tools/__pycache__/dump_vanilla_conf.cpython-311.pyc
Normal file
BIN
tools/__pycache__/dump_vanilla_conf.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tools/__pycache__/dump_vanilla_conf.cpython-312.pyc
Normal file
BIN
tools/__pycache__/dump_vanilla_conf.cpython-312.pyc
Normal file
Binary file not shown.
717
tools/dump_vanilla_conf.py
Normal file
717
tools/dump_vanilla_conf.py
Normal file
@@ -0,0 +1,717 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Script for generating default wsmod config from a vanilla game's files
|
||||
warning: bad
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import struct
|
||||
from collections import namedtuple
|
||||
import logging
|
||||
import sys
|
||||
import json
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
VANILLA_ROOT_PATH = Path(
|
||||
"/mnt/c/Users/ComplexPlane/Documents/projects/romhack/smb2imm/files"
|
||||
)
|
||||
|
||||
CourseCommand = namedtuple("CourseCommand", ["opcode", "type", "value"])
|
||||
SmStageInfo = namedtuple("SmStageInfo", ["stage_id", "difficulty"])
|
||||
|
||||
# CMD opcodes
|
||||
CMD_IF = 0
|
||||
CMD_THEN = 1
|
||||
CMD_FLOOR = 2
|
||||
CMD_COURSE_END = 3
|
||||
|
||||
# CMD_IF conditions
|
||||
IF_FLOOR_CLEAR = 0
|
||||
IF_GOAL_TYPE = 2
|
||||
|
||||
# CMD_THEN actions
|
||||
THEN_JUMP_FLOOR = 0
|
||||
THEN_END_COURSE = 2
|
||||
|
||||
# CMD_FLOOR value types
|
||||
FLOOR_STAGE_ID = 0
|
||||
FLOOR_TIME = 1
|
||||
|
||||
|
||||
def get_theme_and_music_ids(stage_id, stage_id_to_theme_id, theme_id_to_music_id):
|
||||
if stage_id < 0 or stage_id >= len(stage_id_to_theme_id):
|
||||
logging.warning("Stage id %s out of range for theme map; using theme 0", stage_id)
|
||||
theme_id = 0
|
||||
else:
|
||||
theme_id = stage_id_to_theme_id[stage_id]
|
||||
if theme_id > 42:
|
||||
theme_id = 42
|
||||
if theme_id < 0 or theme_id >= len(theme_id_to_music_id):
|
||||
logging.warning("Theme id %s out of range for music map; using 0", theme_id)
|
||||
music_id = 0
|
||||
else:
|
||||
music_id = theme_id_to_music_id[theme_id]
|
||||
return (theme_id, music_id)
|
||||
|
||||
|
||||
def parse_cm_course(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
bonus_stage_ids,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
start,
|
||||
count=None,
|
||||
max_cmds=1024,
|
||||
strict=True,
|
||||
):
|
||||
def raise_error(message: str):
|
||||
logging.error(message)
|
||||
if strict:
|
||||
raise SystemExit(message)
|
||||
raise ValueError(message)
|
||||
|
||||
cmds: list[CourseCommand] = []
|
||||
course_cmd_size = 0x1C
|
||||
|
||||
if count is None:
|
||||
i = 0
|
||||
while start + (i + 1) * course_cmd_size <= len(mainloop_buffer) and i < max_cmds:
|
||||
course_cmd = CourseCommand._make(
|
||||
struct.unpack_from(
|
||||
">BBxxI20x",
|
||||
mainloop_buffer,
|
||||
start + i * course_cmd_size,
|
||||
)
|
||||
)
|
||||
cmds.append(course_cmd)
|
||||
if course_cmd.opcode == CMD_COURSE_END:
|
||||
break
|
||||
i += 1
|
||||
else:
|
||||
for i in range(count):
|
||||
course_cmd = CourseCommand._make(
|
||||
struct.unpack_from(
|
||||
">BBxxI20x",
|
||||
mainloop_buffer,
|
||||
start + i * course_cmd_size,
|
||||
)
|
||||
)
|
||||
cmds.append(course_cmd)
|
||||
|
||||
# Course commands to stage infos
|
||||
cm_stage_infos = []
|
||||
stage_id = 0
|
||||
stage_time = 60 * 60
|
||||
blue_jump = None
|
||||
green_jump = None
|
||||
red_jump = None
|
||||
last_goal_type = None
|
||||
first = True
|
||||
finished = False
|
||||
|
||||
for cmd in cmds:
|
||||
if cmd.opcode == CMD_FLOOR:
|
||||
if cmd.type == FLOOR_STAGE_ID:
|
||||
if not first:
|
||||
if blue_jump is None:
|
||||
raise_error("Invalid blue goal jump")
|
||||
|
||||
theme_id, music_id = get_theme_and_music_ids(
|
||||
stage_id, stage_id_to_theme_id_map, theme_id_to_music_id_map
|
||||
)
|
||||
|
||||
stage_name = (
|
||||
stgname_lines[stage_id]
|
||||
if 0 <= stage_id < len(stgname_lines)
|
||||
else f"Stage {stage_id}"
|
||||
)
|
||||
cm_stage_infos.append(
|
||||
{
|
||||
"stage_id": stage_id,
|
||||
"name": stage_name,
|
||||
"theme_id": theme_id,
|
||||
"music_id": music_id,
|
||||
"time_limit": float(stage_time / 60),
|
||||
"blue_goal_jump": blue_jump,
|
||||
"green_goal_jump": green_jump
|
||||
if green_jump is not None
|
||||
else blue_jump,
|
||||
"red_goal_jump": red_jump
|
||||
if red_jump is not None
|
||||
else blue_jump,
|
||||
"is_bonus_stage": stage_id in bonus_stage_ids,
|
||||
}
|
||||
)
|
||||
stage_id = 0
|
||||
stage_time = 60 * 60
|
||||
blue_jump = None
|
||||
green_jump = None
|
||||
red_jump = None
|
||||
last_goal_type = None
|
||||
|
||||
stage_id = cmd.value
|
||||
first = False
|
||||
|
||||
elif cmd.type == FLOOR_TIME:
|
||||
stage_time = cmd.value
|
||||
else:
|
||||
raise_error(f"Invalid CMD_FLOOR opcode type: {cmd.type}")
|
||||
|
||||
elif cmd.opcode == CMD_IF:
|
||||
if cmd.type == IF_FLOOR_CLEAR:
|
||||
last_goal_type = None
|
||||
elif cmd.type == IF_GOAL_TYPE:
|
||||
last_goal_type = cmd.value
|
||||
else:
|
||||
raise_error(f"Invalid CMD_IF opcode type: {cmd.type}")
|
||||
|
||||
elif cmd.opcode == CMD_THEN:
|
||||
if cmd.type == THEN_JUMP_FLOOR:
|
||||
if last_goal_type is None:
|
||||
if blue_jump is None:
|
||||
blue_jump = cmd.value
|
||||
if green_jump is None:
|
||||
green_jump = cmd.value
|
||||
if red_jump is None:
|
||||
red_jump = cmd.value
|
||||
elif last_goal_type == 0:
|
||||
blue_jump = cmd.value
|
||||
elif last_goal_type == 1:
|
||||
green_jump = cmd.value
|
||||
elif last_goal_type == 2:
|
||||
red_jump = cmd.value
|
||||
else:
|
||||
raise_error(f"Invalid last goal type: {last_goal_type}")
|
||||
elif cmd.type == THEN_END_COURSE:
|
||||
# Jumps are irrelevant, this is end of difficulty
|
||||
blue_jump = 1
|
||||
green_jump = 1
|
||||
red_jump = 1
|
||||
else:
|
||||
raise_error(f"Invalid CMD_THEN opcode type: {cmd.type}")
|
||||
|
||||
elif cmd.opcode == CMD_COURSE_END:
|
||||
if blue_jump is None:
|
||||
raise_error("Invalid blue goal jump")
|
||||
theme_id, music_id = get_theme_and_music_ids(
|
||||
stage_id, stage_id_to_theme_id_map, theme_id_to_music_id_map
|
||||
)
|
||||
stage_name = (
|
||||
stgname_lines[stage_id]
|
||||
if 0 <= stage_id < len(stgname_lines)
|
||||
else f"Stage {stage_id}"
|
||||
)
|
||||
cm_stage_infos.append(
|
||||
{
|
||||
"stage_id": stage_id,
|
||||
"name": stage_name,
|
||||
"theme_id": theme_id,
|
||||
"music_id": music_id,
|
||||
"time_limit": float(stage_time / 60),
|
||||
"blue_goal_jump": blue_jump,
|
||||
"green_goal_jump": green_jump
|
||||
if green_jump is not None
|
||||
else blue_jump,
|
||||
"red_goal_jump": red_jump if red_jump is not None else blue_jump,
|
||||
"is_bonus_stage": stage_id in bonus_stage_ids,
|
||||
}
|
||||
)
|
||||
finished = True
|
||||
|
||||
else:
|
||||
raise_error(f"Invalid opcode: {cmd.opcode}")
|
||||
|
||||
if not finished:
|
||||
raise_error("Course command list ended early")
|
||||
|
||||
return cm_stage_infos
|
||||
|
||||
|
||||
def annotate_cm_layout_dump(dump: str) -> str:
|
||||
lines = dump.split("\n")
|
||||
out_lines: list[str] = []
|
||||
|
||||
last_course = None
|
||||
floor_num = 1
|
||||
for line in lines:
|
||||
|
||||
old_floor_num = floor_num
|
||||
floor_num = 1
|
||||
if '"beginner"' in line:
|
||||
last_course = "Beginner"
|
||||
elif '"beginner_extra"' in line:
|
||||
last_course = "Beginner Extra"
|
||||
elif '"advanced"' in line:
|
||||
last_course = "Advanced"
|
||||
elif '"advanced_extra"' in line:
|
||||
last_course = "Advanced Extra"
|
||||
elif '"expert"' in line:
|
||||
last_course = "Expert"
|
||||
elif '"expert_extra"' in line:
|
||||
last_course = "Expert Extra"
|
||||
elif '"master"' in line:
|
||||
last_course = "Master"
|
||||
elif '"master_extra"' in line:
|
||||
last_course = "Master Extra"
|
||||
else:
|
||||
# Don't reset floor num if new difficulty not detected
|
||||
floor_num = old_floor_num
|
||||
|
||||
new_line = line[:]
|
||||
if "{" in new_line and last_course is not None:
|
||||
new_line += f" // {last_course} {floor_num}"
|
||||
floor_num += 1
|
||||
|
||||
new_line = new_line.replace("60.0", "60.00")
|
||||
new_line = new_line.replace("30.0", "30.00")
|
||||
|
||||
out_lines.append(new_line)
|
||||
|
||||
# if '"time_limit"' in new_line:
|
||||
# out_lines.append("")
|
||||
|
||||
return "\n".join(out_lines)
|
||||
|
||||
|
||||
def dump_storymode_world_layout(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
start,
|
||||
):
|
||||
stage_info_size = 0x4
|
||||
|
||||
stage_infos: list[SmStageInfo] = []
|
||||
for i in range(10):
|
||||
offs = start + i * stage_info_size
|
||||
stage_info = SmStageInfo._make(struct.unpack_from(">hh", mainloop_buffer, offs))
|
||||
stage_infos.append(stage_info)
|
||||
|
||||
out_json_array = []
|
||||
for stage_info in stage_infos:
|
||||
time_limit = 60 * 60 if stage_info.stage_id != 30 else 60 * 30
|
||||
theme_id, music_id = get_theme_and_music_ids(
|
||||
stage_info.stage_id, stage_id_to_theme_id_map, theme_id_to_music_id_map
|
||||
)
|
||||
stage_name = (
|
||||
stgname_lines[stage_info.stage_id]
|
||||
if 0 <= stage_info.stage_id < len(stgname_lines)
|
||||
else f"Stage {stage_info.stage_id}"
|
||||
)
|
||||
out_json_array.append(
|
||||
{
|
||||
"stage_id": stage_info.stage_id,
|
||||
"name": stage_name,
|
||||
"theme_id": theme_id,
|
||||
"music_id": music_id,
|
||||
"time_limit": float(time_limit / 60),
|
||||
"difficulty": stage_info.difficulty,
|
||||
}
|
||||
)
|
||||
|
||||
return out_json_array
|
||||
|
||||
|
||||
def annotate_story_layout_dump(dump: str) -> str:
|
||||
lines = dump.split("\n")
|
||||
out_lines: list[str] = []
|
||||
|
||||
last_course = None
|
||||
world = -1
|
||||
stage = 0
|
||||
for line in lines:
|
||||
new_line = line[:]
|
||||
|
||||
if "[" in line:
|
||||
world += 1
|
||||
stage = 0
|
||||
if world >= 1:
|
||||
new_line += f" // World {world}"
|
||||
if "{" in line:
|
||||
stage += 1
|
||||
new_line += f" // Stage {world}-{stage}"
|
||||
|
||||
new_line = new_line.replace("60.0", "60.00")
|
||||
new_line = new_line.replace("30.0", "30.00")
|
||||
|
||||
out_lines.append(new_line)
|
||||
|
||||
# if '"time_limit"' in new_line:
|
||||
# out_lines.append("")
|
||||
|
||||
return "\n".join(out_lines)
|
||||
|
||||
|
||||
def list_stage_ids(stage_dir: Path) -> Set[int]:
|
||||
ids: Set[int] = set()
|
||||
if not stage_dir.exists():
|
||||
return ids
|
||||
for path in stage_dir.glob("STAGE*.lz"):
|
||||
name = path.name
|
||||
if len(name) == 11 and name.startswith("STAGE") and name.endswith(".lz"):
|
||||
try:
|
||||
ids.add(int(name[5:8]))
|
||||
except ValueError:
|
||||
continue
|
||||
return ids
|
||||
|
||||
|
||||
def collect_stage_ids_from_cm(cm_layout: Dict[str, List[dict]]) -> List[int]:
|
||||
ids: List[int] = []
|
||||
for entries in cm_layout.values():
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict) and isinstance(entry.get("stage_id"), int):
|
||||
ids.append(entry["stage_id"])
|
||||
return ids
|
||||
|
||||
|
||||
def collect_stage_ids_from_story(worlds: List[List[dict]]) -> List[int]:
|
||||
ids: List[int] = []
|
||||
for world in worlds:
|
||||
if not isinstance(world, list):
|
||||
continue
|
||||
for entry in world:
|
||||
if isinstance(entry, dict) and isinstance(entry.get("stage_id"), int):
|
||||
ids.append(entry["stage_id"])
|
||||
return ids
|
||||
|
||||
|
||||
def validate_stage_ids(
|
||||
stage_ids: List[int],
|
||||
valid_ids: Set[int],
|
||||
label: str,
|
||||
named_ids: Optional[Set[int]] = None,
|
||||
min_named_ratio: float = 0.0,
|
||||
) -> bool:
|
||||
if not stage_ids or not valid_ids:
|
||||
return True
|
||||
if any(stage_id < 0 for stage_id in stage_ids):
|
||||
logging.warning("%s contains negative stage ids", label)
|
||||
return False
|
||||
invalid = [sid for sid in stage_ids if sid not in valid_ids]
|
||||
if not invalid:
|
||||
if named_ids and min_named_ratio > 0:
|
||||
named_count = sum(1 for sid in stage_ids if sid in named_ids)
|
||||
ratio = named_count / max(1, len(stage_ids))
|
||||
if ratio < min_named_ratio:
|
||||
logging.warning("%s has low named stage ratio (%.1f%%)", label, ratio * 100)
|
||||
return False
|
||||
return True
|
||||
ratio = len(invalid) / max(1, len(stage_ids))
|
||||
logging.warning("%s has %d invalid stage ids (%.1f%%)", label, len(invalid), ratio * 100)
|
||||
return ratio < 0.1
|
||||
|
||||
|
||||
def find_course_offsets(
|
||||
data: bytes,
|
||||
stage_ids: Set[int],
|
||||
named_stage_ids: Set[int],
|
||||
min_stages: int = 10,
|
||||
max_cmds: int = 512,
|
||||
) -> List[Tuple[int, int]]:
|
||||
course_cmd_size = 0x1C
|
||||
candidates: List[Tuple[int, int, float]] = []
|
||||
for off in range(0, len(data) - course_cmd_size, 4):
|
||||
opcode = data[off]
|
||||
cmd_type = data[off + 1]
|
||||
if opcode != CMD_FLOOR or cmd_type != FLOOR_STAGE_ID:
|
||||
continue
|
||||
stage_count = 0
|
||||
valid_stage_count = 0
|
||||
cmd_count = 0
|
||||
finished = False
|
||||
for i in range(max_cmds):
|
||||
cmd_off = off + i * course_cmd_size
|
||||
if cmd_off + course_cmd_size > len(data):
|
||||
break
|
||||
opcode = data[cmd_off]
|
||||
cmd_type = data[cmd_off + 1]
|
||||
value = struct.unpack_from(">I", data, cmd_off + 4)[0]
|
||||
cmd_count += 1
|
||||
if opcode == CMD_FLOOR:
|
||||
if cmd_type == FLOOR_STAGE_ID:
|
||||
stage_count += 1
|
||||
if value in stage_ids:
|
||||
valid_stage_count += 1
|
||||
elif cmd_type != FLOOR_TIME:
|
||||
break
|
||||
elif opcode == CMD_IF:
|
||||
if cmd_type not in (IF_FLOOR_CLEAR, IF_GOAL_TYPE):
|
||||
break
|
||||
elif opcode == CMD_THEN:
|
||||
if cmd_type not in (THEN_JUMP_FLOOR, THEN_END_COURSE):
|
||||
break
|
||||
elif opcode == CMD_COURSE_END:
|
||||
finished = True
|
||||
break
|
||||
else:
|
||||
break
|
||||
if not finished or stage_count < min_stages:
|
||||
continue
|
||||
ratio = valid_stage_count / max(1, stage_count)
|
||||
named_count = 0
|
||||
if named_stage_ids:
|
||||
for i in range(max_cmds):
|
||||
cmd_off = off + i * course_cmd_size
|
||||
if cmd_off + course_cmd_size > len(data):
|
||||
break
|
||||
opcode = data[cmd_off]
|
||||
cmd_type = data[cmd_off + 1]
|
||||
if opcode == CMD_FLOOR and cmd_type == FLOOR_STAGE_ID:
|
||||
value = struct.unpack_from(">I", data, cmd_off + 4)[0]
|
||||
if value in named_stage_ids:
|
||||
named_count += 1
|
||||
named_ratio = named_count / max(1, stage_count)
|
||||
else:
|
||||
named_ratio = 0.0
|
||||
score = stage_count * ratio - cmd_count * 0.05 + named_ratio
|
||||
candidates.append((off, cmd_count, score))
|
||||
|
||||
candidates.sort(key=lambda item: (-item[2], item[0]))
|
||||
selected: List[Tuple[int, int]] = []
|
||||
used_ranges: List[Tuple[int, int]] = []
|
||||
for off, cmd_count, _ in candidates:
|
||||
start = off
|
||||
end = off + cmd_count * course_cmd_size
|
||||
if any(start < rng_end and end > rng_start for rng_start, rng_end in used_ranges):
|
||||
continue
|
||||
selected.append((off, cmd_count))
|
||||
used_ranges.append((start, end))
|
||||
if len(selected) >= 8:
|
||||
break
|
||||
selected.sort(key=lambda item: item[0])
|
||||
return selected
|
||||
|
||||
|
||||
def find_story_block_offset(
|
||||
data: bytes,
|
||||
stage_ids: Set[int],
|
||||
named_stage_ids: Set[int],
|
||||
) -> Optional[int]:
|
||||
entry_size = 4
|
||||
world_count = 10
|
||||
stages_per_world = 10
|
||||
block_size = world_count * stages_per_world * entry_size
|
||||
for off in range(0, len(data) - block_size, 4):
|
||||
valid = True
|
||||
unique_ids: Set[int] = set()
|
||||
named_count = 0
|
||||
for idx in range(world_count * stages_per_world):
|
||||
entry_off = off + idx * entry_size
|
||||
stage_id, difficulty = struct.unpack_from(">hh", data, entry_off)
|
||||
if stage_id not in stage_ids:
|
||||
valid = False
|
||||
break
|
||||
if difficulty < 0 or difficulty > 5:
|
||||
valid = False
|
||||
break
|
||||
unique_ids.add(stage_id)
|
||||
if stage_id in named_stage_ids:
|
||||
named_count += 1
|
||||
if valid:
|
||||
if len(unique_ids) < 20:
|
||||
continue
|
||||
if named_stage_ids and named_count / max(1, world_count * stages_per_world) < 0.3:
|
||||
continue
|
||||
return off
|
||||
return None
|
||||
|
||||
|
||||
def is_story_world_valid(
|
||||
data: bytes,
|
||||
offset: int,
|
||||
stage_ids: Set[int],
|
||||
named_stage_ids: Set[int],
|
||||
) -> bool:
|
||||
unique_ids: Set[int] = set()
|
||||
named_count = 0
|
||||
for idx in range(10):
|
||||
stage_id, difficulty = struct.unpack_from(">hh", data, offset + idx * 4)
|
||||
if stage_id not in stage_ids:
|
||||
return False
|
||||
if difficulty < 0 or difficulty > 5:
|
||||
return False
|
||||
unique_ids.add(stage_id)
|
||||
if stage_id in named_stage_ids:
|
||||
named_count += 1
|
||||
if len(unique_ids) < 3:
|
||||
return False
|
||||
if named_stage_ids and named_count / 10 < 0.3:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def load_vanilla_course_data(
|
||||
rom_dir: Path,
|
||||
*,
|
||||
course_cmd_counts: Optional[Dict[str, int]] = None,
|
||||
world_offsets: Optional[List[int]] = None,
|
||||
) -> dict:
|
||||
mainloop_path = rom_dir / "mkb2.main_loop.rel"
|
||||
stgname_path = rom_dir / "stgname" / "usa.str"
|
||||
if not mainloop_path.exists():
|
||||
raise FileNotFoundError(f"missing {mainloop_path}")
|
||||
if not stgname_path.exists():
|
||||
raise FileNotFoundError(f"missing {stgname_path}")
|
||||
|
||||
mainloop_buffer = mainloop_path.read_bytes()
|
||||
stgname_lines = stgname_path.read_text(encoding="ascii", errors="ignore").splitlines()
|
||||
named_stage_ids = {i for i, name in enumerate(stgname_lines) if name and name != "-"}
|
||||
|
||||
bonus_stage_ids = struct.unpack_from(">9i", mainloop_buffer, 0x00176118)
|
||||
stage_id_to_theme_id_map = struct.unpack_from(">428B", mainloop_buffer, 0x00204E48)
|
||||
theme_id_to_music_id_map = struct.unpack_from(">43h", mainloop_buffer, 0x0016E738)
|
||||
|
||||
stage_ids = list_stage_ids(rom_dir / "stage")
|
||||
|
||||
# Parse challenge mode entries using default offsets first.
|
||||
counts = course_cmd_counts or {}
|
||||
default_course_offsets = [
|
||||
("beginner", 0x002075B0),
|
||||
("advanced", 0x00207914),
|
||||
("expert", 0x00208634),
|
||||
("beginner_extra", 0x00209CF4),
|
||||
("advanced_extra", 0x0020A0C8),
|
||||
("expert_extra", 0x0020A448),
|
||||
("master", 0x0020A8E0),
|
||||
("master_extra", 0x0020ACB4),
|
||||
]
|
||||
cm_layout: Dict[str, List[dict]] = {}
|
||||
for name, offset in default_course_offsets:
|
||||
try:
|
||||
cm_layout[name] = parse_cm_course(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
bonus_stage_ids,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
offset,
|
||||
counts.get(name),
|
||||
strict=True,
|
||||
)
|
||||
except Exception:
|
||||
cm_layout = {}
|
||||
break
|
||||
|
||||
if cm_layout:
|
||||
cm_ids = collect_stage_ids_from_cm(cm_layout)
|
||||
if not validate_stage_ids(cm_ids, stage_ids, "challenge courses", named_ids=named_stage_ids):
|
||||
cm_layout = {}
|
||||
|
||||
if not cm_layout and stage_ids:
|
||||
logging.warning("Default course offsets invalid; scanning for course tables.")
|
||||
offsets = find_course_offsets(mainloop_buffer, stage_ids, named_stage_ids)
|
||||
order = [name for name, _ in default_course_offsets]
|
||||
for idx, (offset, cmd_count) in enumerate(offsets[: len(order)]):
|
||||
name = order[idx]
|
||||
try:
|
||||
cm_layout[name] = parse_cm_course(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
bonus_stage_ids,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
offset,
|
||||
cmd_count,
|
||||
strict=False,
|
||||
)
|
||||
except Exception:
|
||||
cm_layout = {}
|
||||
break
|
||||
|
||||
if not cm_layout:
|
||||
raise SystemExit("Failed to locate challenge course tables.")
|
||||
|
||||
if world_offsets is None:
|
||||
world_offsets = [
|
||||
0x0020b448,
|
||||
0x0020b470,
|
||||
0x0020b498,
|
||||
0x0020b4c0,
|
||||
0x0020b4e8,
|
||||
0x0020b510,
|
||||
0x0020b538,
|
||||
0x0020b560,
|
||||
0x0020b588,
|
||||
0x0020b5b0,
|
||||
]
|
||||
worlds = []
|
||||
for offs in world_offsets:
|
||||
if stage_ids and not is_story_world_valid(mainloop_buffer, offs, stage_ids, named_stage_ids):
|
||||
worlds = []
|
||||
break
|
||||
world = dump_storymode_world_layout(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
offs,
|
||||
)
|
||||
worlds.append(world)
|
||||
|
||||
if worlds:
|
||||
story_ids = collect_stage_ids_from_story(worlds)
|
||||
if not validate_stage_ids(
|
||||
story_ids,
|
||||
stage_ids,
|
||||
"story worlds",
|
||||
named_ids=named_stage_ids,
|
||||
min_named_ratio=0.3,
|
||||
):
|
||||
worlds = []
|
||||
|
||||
if not worlds and stage_ids:
|
||||
logging.warning("Default story offsets invalid; scanning for story table.")
|
||||
base_off = find_story_block_offset(mainloop_buffer, stage_ids, named_stage_ids)
|
||||
if base_off is not None:
|
||||
world_offsets = [base_off + i * 0x28 for i in range(10)]
|
||||
for offs in world_offsets:
|
||||
world = dump_storymode_world_layout(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
offs,
|
||||
)
|
||||
worlds.append(world)
|
||||
|
||||
if not worlds:
|
||||
logging.warning("Story world data not found; output will omit story worlds.")
|
||||
|
||||
return {
|
||||
"challenge": cm_layout,
|
||||
"story": worlds,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump vanilla challenge/story course data from extracted SMB2 files."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rom",
|
||||
type=Path,
|
||||
default=VANILLA_ROOT_PATH,
|
||||
help="Path to extracted ROM folder (containing mkb2.main_loop.rel)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
data = load_vanilla_course_data(args.rom)
|
||||
cm_layout_dump = json.dumps(data["challenge"], indent=4)
|
||||
annotated_cm_layout_dump = annotate_cm_layout_dump(cm_layout_dump)
|
||||
print(annotated_cm_layout_dump)
|
||||
|
||||
story_layout_dump = json.dumps(data["story"], indent=4)
|
||||
annotated_story_layout_dump = annotate_story_layout_dump(story_layout_dump)
|
||||
# print(annotated_story_layout_dump)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
497
tools/dump_vanilla_conf_original.py
Normal file
497
tools/dump_vanilla_conf_original.py
Normal file
@@ -0,0 +1,497 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Script for generating default wsmod config from a vanilla game's files
|
||||
warning: bad
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import struct
|
||||
from collections import namedtuple
|
||||
import logging
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
|
||||
VANILLA_ROOT_PATH = Path(
|
||||
"/mnt/c/Users/ComplexPlane/Documents/projects/romhack/smb2imm/files"
|
||||
)
|
||||
|
||||
CourseCommand = namedtuple("CourseCommand", ["opcode", "type", "value"])
|
||||
SmStageInfo = namedtuple("SmStageInfo", ["stage_id", "difficulty"])
|
||||
|
||||
# CMD opcodes
|
||||
CMD_IF = 0
|
||||
CMD_THEN = 1
|
||||
CMD_FLOOR = 2
|
||||
CMD_COURSE_END = 3
|
||||
|
||||
# CMD_IF conditions
|
||||
IF_FLOOR_CLEAR = 0
|
||||
IF_GOAL_TYPE = 2
|
||||
|
||||
# CMD_THEN actions
|
||||
THEN_JUMP_FLOOR = 0
|
||||
THEN_END_COURSE = 2
|
||||
|
||||
# CMD_FLOOR value types
|
||||
FLOOR_STAGE_ID = 0
|
||||
FLOOR_TIME = 1
|
||||
|
||||
|
||||
def get_theme_and_music_ids(stage_id, stage_id_to_theme_id, theme_id_to_music_id):
|
||||
if stage_id < 0 or stage_id >= len(stage_id_to_theme_id):
|
||||
logging.warning("Stage id %s out of range for theme map; using theme 0", stage_id)
|
||||
theme_id = 0
|
||||
else:
|
||||
theme_id = stage_id_to_theme_id[stage_id]
|
||||
if theme_id > 42:
|
||||
theme_id = 42
|
||||
if theme_id < 0 or theme_id >= len(theme_id_to_music_id):
|
||||
logging.warning("Theme id %s out of range for music map; using 0", theme_id)
|
||||
music_id = 0
|
||||
else:
|
||||
music_id = theme_id_to_music_id[theme_id]
|
||||
return (theme_id, music_id)
|
||||
|
||||
|
||||
def parse_cm_course(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
bonus_stage_ids,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
start,
|
||||
count,
|
||||
):
|
||||
cmds: list[CourseCommand] = []
|
||||
course_cmd_size = 0x1C
|
||||
|
||||
for i in range(count):
|
||||
course_cmd = CourseCommand._make(
|
||||
struct.unpack_from(
|
||||
">BBxxI20x",
|
||||
mainloop_buffer,
|
||||
start + i * course_cmd_size,
|
||||
)
|
||||
)
|
||||
cmds.append(course_cmd)
|
||||
|
||||
# Course commands to stage infos
|
||||
cm_stage_infos = []
|
||||
stage_id = 0
|
||||
stage_time = 60 * 60
|
||||
blue_jump = None
|
||||
green_jump = None
|
||||
red_jump = None
|
||||
last_goal_type = None
|
||||
first = True
|
||||
finished = False
|
||||
|
||||
for cmd in cmds:
|
||||
if cmd.opcode == CMD_FLOOR:
|
||||
if cmd.type == FLOOR_STAGE_ID:
|
||||
if not first:
|
||||
if blue_jump is None:
|
||||
logging.error("Invalid blue goal jump")
|
||||
sys.exit(1)
|
||||
|
||||
theme_id, music_id = get_theme_and_music_ids(
|
||||
stage_id, stage_id_to_theme_id_map, theme_id_to_music_id_map
|
||||
)
|
||||
|
||||
cm_stage_infos.append(
|
||||
{
|
||||
"stage_id": stage_id,
|
||||
"name": stgname_lines[stage_id],
|
||||
"theme_id": theme_id,
|
||||
"music_id": music_id,
|
||||
"time_limit": float(stage_time / 60),
|
||||
"blue_goal_jump": blue_jump,
|
||||
"green_goal_jump": green_jump
|
||||
if green_jump is not None
|
||||
else blue_jump,
|
||||
"red_goal_jump": red_jump
|
||||
if red_jump is not None
|
||||
else blue_jump,
|
||||
"is_bonus_stage": stage_id in bonus_stage_ids,
|
||||
}
|
||||
)
|
||||
stage_id = 0
|
||||
stage_time = 60 * 60
|
||||
blue_jump = None
|
||||
green_jump = None
|
||||
red_jump = None
|
||||
last_goal_type = None
|
||||
|
||||
stage_id = cmd.value
|
||||
first = False
|
||||
|
||||
elif cmd.type == FLOOR_TIME:
|
||||
stage_time = cmd.value
|
||||
else:
|
||||
logging.error(f"Invalid CMD_FLOOR opcode type: {cmd.type}")
|
||||
sys.exit(1)
|
||||
|
||||
elif cmd.opcode == CMD_IF:
|
||||
if cmd.type == IF_FLOOR_CLEAR:
|
||||
last_goal_type = None
|
||||
elif cmd.type == IF_GOAL_TYPE:
|
||||
last_goal_type = cmd.value
|
||||
else:
|
||||
logging.error(f"Invalid CMD_IF opcode type: {cmd.type}")
|
||||
sys.exit(1)
|
||||
|
||||
elif cmd.opcode == CMD_THEN:
|
||||
if cmd.type == THEN_JUMP_FLOOR:
|
||||
if last_goal_type is None:
|
||||
if blue_jump is None:
|
||||
blue_jump = cmd.value
|
||||
if green_jump is None:
|
||||
green_jump = cmd.value
|
||||
if red_jump is None:
|
||||
red_jump = cmd.value
|
||||
elif last_goal_type == 0:
|
||||
blue_jump = cmd.value
|
||||
elif last_goal_type == 1:
|
||||
green_jump = cmd.value
|
||||
elif last_goal_type == 2:
|
||||
red_jump = cmd.value
|
||||
else:
|
||||
logging.error(f"Invalid last goal type: {last_goal_type}")
|
||||
sys.exit(1)
|
||||
elif cmd.type == THEN_END_COURSE:
|
||||
# Jumps are irrelevant, this is end of difficulty
|
||||
blue_jump = 1
|
||||
green_jump = 1
|
||||
red_jump = 1
|
||||
else:
|
||||
logging.error(f"Invalid CMD_THEN opcode type: {cmd.type}")
|
||||
sys.exit(1)
|
||||
|
||||
elif cmd.opcode == CMD_COURSE_END:
|
||||
if blue_jump is None:
|
||||
logging.error("Invalid blue goal jump")
|
||||
sys.exit(1)
|
||||
theme_id, music_id = get_theme_and_music_ids(
|
||||
stage_id, stage_id_to_theme_id_map, theme_id_to_music_id_map
|
||||
)
|
||||
cm_stage_infos.append(
|
||||
{
|
||||
"stage_id": stage_id,
|
||||
"name": stgname_lines[stage_id],
|
||||
"theme_id": theme_id,
|
||||
"music_id": music_id,
|
||||
"time_limit": float(stage_time / 60),
|
||||
"blue_goal_jump": blue_jump,
|
||||
"green_goal_jump": green_jump
|
||||
if green_jump is not None
|
||||
else blue_jump,
|
||||
"red_goal_jump": red_jump if red_jump is not None else blue_jump,
|
||||
"is_bonus_stage": stage_id in bonus_stage_ids,
|
||||
}
|
||||
)
|
||||
finished = True
|
||||
|
||||
else:
|
||||
logging.error(f"Invalid opcode: {cmd.opcode}")
|
||||
sys.exit(1)
|
||||
|
||||
if not finished:
|
||||
logging.error("Course command list ended early")
|
||||
sys.exit(1)
|
||||
|
||||
return cm_stage_infos
|
||||
|
||||
|
||||
def annotate_cm_layout_dump(dump: str) -> str:
|
||||
lines = dump.split("\n")
|
||||
out_lines: list[str] = []
|
||||
|
||||
last_course = None
|
||||
floor_num = 1
|
||||
for line in lines:
|
||||
|
||||
old_floor_num = floor_num
|
||||
floor_num = 1
|
||||
if '"beginner"' in line:
|
||||
last_course = "Beginner"
|
||||
elif '"beginner_extra"' in line:
|
||||
last_course = "Beginner Extra"
|
||||
elif '"advanced"' in line:
|
||||
last_course = "Advanced"
|
||||
elif '"advanced_extra"' in line:
|
||||
last_course = "Advanced Extra"
|
||||
elif '"expert"' in line:
|
||||
last_course = "Expert"
|
||||
elif '"expert_extra"' in line:
|
||||
last_course = "Expert Extra"
|
||||
elif '"master"' in line:
|
||||
last_course = "Master"
|
||||
elif '"master_extra"' in line:
|
||||
last_course = "Master Extra"
|
||||
else:
|
||||
# Don't reset floor num if new difficulty not detected
|
||||
floor_num = old_floor_num
|
||||
|
||||
new_line = line[:]
|
||||
if "{" in new_line and last_course is not None:
|
||||
new_line += f" // {last_course} {floor_num}"
|
||||
floor_num += 1
|
||||
|
||||
new_line = new_line.replace("60.0", "60.00")
|
||||
new_line = new_line.replace("30.0", "30.00")
|
||||
|
||||
out_lines.append(new_line)
|
||||
|
||||
# if '"time_limit"' in new_line:
|
||||
# out_lines.append("")
|
||||
|
||||
return "\n".join(out_lines)
|
||||
|
||||
|
||||
def dump_storymode_world_layout(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
start,
|
||||
):
|
||||
stage_info_size = 0x4
|
||||
|
||||
stage_infos: list[SmStageInfo] = []
|
||||
for i in range(10):
|
||||
offs = start + i * stage_info_size
|
||||
stage_info = SmStageInfo._make(struct.unpack_from(">hh", mainloop_buffer, offs))
|
||||
stage_infos.append(stage_info)
|
||||
|
||||
out_json_array = []
|
||||
for stage_info in stage_infos:
|
||||
time_limit = 60 * 60 if stage_info.stage_id != 30 else 60 * 30
|
||||
theme_id, music_id = get_theme_and_music_ids(
|
||||
stage_info.stage_id, stage_id_to_theme_id_map, theme_id_to_music_id_map
|
||||
)
|
||||
out_json_array.append(
|
||||
{
|
||||
"stage_id": stage_info.stage_id,
|
||||
"name": stgname_lines[stage_info.stage_id],
|
||||
"theme_id": theme_id,
|
||||
"music_id": music_id,
|
||||
"time_limit": float(time_limit / 60),
|
||||
"difficulty": stage_info.difficulty,
|
||||
}
|
||||
)
|
||||
|
||||
return out_json_array
|
||||
|
||||
|
||||
def annotate_story_layout_dump(dump: str) -> str:
|
||||
lines = dump.split("\n")
|
||||
out_lines: list[str] = []
|
||||
|
||||
last_course = None
|
||||
world = -1
|
||||
stage = 0
|
||||
for line in lines:
|
||||
new_line = line[:]
|
||||
|
||||
if "[" in line:
|
||||
world += 1
|
||||
stage = 0
|
||||
if world >= 1:
|
||||
new_line += f" // World {world}"
|
||||
if "{" in line:
|
||||
stage += 1
|
||||
new_line += f" // Stage {world}-{stage}"
|
||||
|
||||
new_line = new_line.replace("60.0", "60.00")
|
||||
new_line = new_line.replace("30.0", "30.00")
|
||||
|
||||
out_lines.append(new_line)
|
||||
|
||||
# if '"time_limit"' in new_line:
|
||||
# out_lines.append("")
|
||||
|
||||
return "\n".join(out_lines)
|
||||
|
||||
|
||||
def list_stage_ids(stage_dir: Path) -> set[int]:
|
||||
ids: set[int] = set()
|
||||
if not stage_dir.exists():
|
||||
return ids
|
||||
for path in stage_dir.glob("STAGE*.lz"):
|
||||
name = path.name
|
||||
if len(name) == 11 and name.startswith("STAGE") and name.endswith(".lz"):
|
||||
try:
|
||||
ids.add(int(name[5:8]))
|
||||
except ValueError:
|
||||
continue
|
||||
return ids
|
||||
|
||||
|
||||
def validate_story_offsets(mainloop_buffer: bytes, world_offsets: list[int], stage_ids: set[int]) -> bool:
|
||||
if not stage_ids:
|
||||
return True
|
||||
for offs in world_offsets:
|
||||
for i in range(10):
|
||||
entry_offs = offs + i * 4
|
||||
if entry_offs + 4 > len(mainloop_buffer):
|
||||
return False
|
||||
stage_id, difficulty = struct.unpack_from(">hh", mainloop_buffer, entry_offs)
|
||||
if stage_id not in stage_ids:
|
||||
return False
|
||||
if difficulty < 0 or difficulty > 5:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump vanilla challenge/story course data from extracted SMB2 files."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rom",
|
||||
type=Path,
|
||||
default=VANILLA_ROOT_PATH,
|
||||
help="Path to extracted ROM folder (containing mkb2.main_loop.rel)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--story-only",
|
||||
action="store_true",
|
||||
help="Skip challenge mode parsing and only dump story worlds.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
root_path = args.rom
|
||||
|
||||
with open(root_path / "mkb2.main_loop.rel", "rb") as f:
|
||||
mainloop_buffer = f.read()
|
||||
with open(root_path / "stgname" / "usa.str", "r") as f:
|
||||
stgname_lines = [s.strip() for s in f.readlines()]
|
||||
|
||||
bonus_stage_ids = struct.unpack_from(">9i", mainloop_buffer, 0x00176118)
|
||||
stage_id_to_theme_id_map = struct.unpack_from(">428B", mainloop_buffer, 0x00204E48)
|
||||
theme_id_to_music_id_map = struct.unpack_from(">43h", mainloop_buffer, 0x0016E738)
|
||||
|
||||
if not args.story_only:
|
||||
# Parse challenge mode entries
|
||||
beginner = parse_cm_course(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
bonus_stage_ids,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
0x002075B0,
|
||||
31,
|
||||
)
|
||||
advanced = parse_cm_course(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
bonus_stage_ids,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
0x00207914,
|
||||
120,
|
||||
)
|
||||
expert = parse_cm_course(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
bonus_stage_ids,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
0x00208634,
|
||||
208,
|
||||
)
|
||||
beginner_extra = parse_cm_course(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
bonus_stage_ids,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
0x00209CF4,
|
||||
35,
|
||||
)
|
||||
advanced_extra = parse_cm_course(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
bonus_stage_ids,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
0x0020A0C8,
|
||||
32,
|
||||
)
|
||||
expert_extra = parse_cm_course(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
bonus_stage_ids,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
0x0020A448,
|
||||
42,
|
||||
)
|
||||
master = parse_cm_course(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
bonus_stage_ids,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
0x0020A8E0,
|
||||
35,
|
||||
)
|
||||
master_extra = parse_cm_course(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
bonus_stage_ids,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
0x0020ACB4,
|
||||
50,
|
||||
)
|
||||
cm_layout = {
|
||||
"beginner": beginner,
|
||||
"beginner_extra": beginner_extra,
|
||||
"advanced": advanced,
|
||||
"advanced_extra": advanced_extra,
|
||||
"expert": expert,
|
||||
"expert_extra": expert_extra,
|
||||
"master": master,
|
||||
"master_extra": master_extra,
|
||||
}
|
||||
|
||||
cm_layout_dump = json.dumps(cm_layout, indent=4)
|
||||
annotated_cm_layout_dump = annotate_cm_layout_dump(cm_layout_dump)
|
||||
print(annotated_cm_layout_dump)
|
||||
|
||||
world_offsets = [
|
||||
0x0020b448,
|
||||
0x0020b470,
|
||||
0x0020b498,
|
||||
0x0020b4c0,
|
||||
0x0020b4e8,
|
||||
0x0020b510,
|
||||
0x0020b538,
|
||||
0x0020b560,
|
||||
0x0020b588,
|
||||
0x0020b5b0,
|
||||
]
|
||||
stage_ids = list_stage_ids(root_path / "stage")
|
||||
if not validate_story_offsets(mainloop_buffer, world_offsets, stage_ids):
|
||||
logging.warning("Story mode offsets do not look valid for this ROM.")
|
||||
return
|
||||
worlds = []
|
||||
for offs in world_offsets:
|
||||
world = dump_storymode_world_layout(
|
||||
mainloop_buffer,
|
||||
stgname_lines,
|
||||
stage_id_to_theme_id_map,
|
||||
theme_id_to_music_id_map,
|
||||
offs,
|
||||
)
|
||||
worlds.append(world)
|
||||
|
||||
story_layout_dump = json.dumps(worlds, indent=4)
|
||||
annotated_story_layout_dump = annotate_story_layout_dump(story_layout_dump)
|
||||
# print(annotated_story_layout_dump)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -682,6 +682,188 @@ def build_pack(
|
||||
print(f' - {warning}')
|
||||
|
||||
|
||||
def load_vanilla_courses_from_rom(
|
||||
rom_dir: Path,
|
||||
) -> Tuple[Dict[str, List[Tuple[int, bool]]], List[List[int]], Dict[int, int], List[str]]:
|
||||
try:
|
||||
from dump_vanilla_conf import load_vanilla_course_data
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f'failed to import dump_vanilla_conf: {exc}') from exc
|
||||
|
||||
data = load_vanilla_course_data(rom_dir)
|
||||
challenge = data.get('challenge') if isinstance(data, dict) else None
|
||||
story = data.get('story') if isinstance(data, dict) else None
|
||||
|
||||
course_name_map = {
|
||||
'beginner': 'Beginner',
|
||||
'beginner_extra': 'Beginner Extra',
|
||||
'advanced': 'Advanced',
|
||||
'advanced_extra': 'Advanced Extra',
|
||||
'expert': 'Expert',
|
||||
'expert_extra': 'Expert Extra',
|
||||
'master': 'Master',
|
||||
'master_extra': 'Master Extra',
|
||||
}
|
||||
|
||||
challenge_courses: Dict[str, List[Tuple[int, bool]]] = {}
|
||||
story_worlds: List[List[int]] = []
|
||||
stage_time_overrides: Dict[int, int] = {}
|
||||
warnings: List[str] = []
|
||||
default_time = 60.0
|
||||
|
||||
def register_time_override(stage_id: int, time_limit: Optional[float]) -> None:
|
||||
if time_limit is None:
|
||||
return
|
||||
if abs(time_limit - default_time) < 0.01:
|
||||
return
|
||||
frames = int(round(time_limit * 60))
|
||||
existing = stage_time_overrides.get(stage_id)
|
||||
if existing is not None and existing != frames:
|
||||
warnings.append(
|
||||
f'stage {stage_id} has conflicting time limits ({existing} vs {frames} frames)'
|
||||
)
|
||||
return
|
||||
stage_time_overrides[stage_id] = frames
|
||||
|
||||
if isinstance(challenge, dict):
|
||||
for key, entries in challenge.items():
|
||||
name = course_name_map.get(key, key)
|
||||
course_entries: List[Tuple[int, bool]] = []
|
||||
if isinstance(entries, list):
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
stage_id = entry.get('stage_id')
|
||||
if not isinstance(stage_id, int):
|
||||
continue
|
||||
bonus = bool(entry.get('is_bonus_stage'))
|
||||
course_entries.append((stage_id, bonus))
|
||||
register_time_override(stage_id, entry.get('time_limit'))
|
||||
if course_entries:
|
||||
challenge_courses[name] = course_entries
|
||||
|
||||
if isinstance(story, list):
|
||||
for world in story:
|
||||
world_entries: List[int] = []
|
||||
if isinstance(world, list):
|
||||
for entry in world:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
stage_id = entry.get('stage_id')
|
||||
if not isinstance(stage_id, int):
|
||||
continue
|
||||
world_entries.append(stage_id)
|
||||
register_time_override(stage_id, entry.get('time_limit'))
|
||||
if world_entries:
|
||||
story_worlds.append(world_entries)
|
||||
|
||||
if not story_worlds:
|
||||
warnings.append('Story mode data not found; leaving story worlds empty.')
|
||||
|
||||
return challenge_courses, story_worlds, stage_time_overrides, warnings
|
||||
|
||||
|
||||
def parse_cmmod_config(config_path: Path) -> Tuple[
|
||||
Dict[str, List[Tuple[int, bool]]],
|
||||
Dict[int, int],
|
||||
List[str],
|
||||
]:
|
||||
warnings: List[str] = []
|
||||
entry_lists: Dict[str, List[Tuple[int, Optional[int]]]] = {}
|
||||
diff_map: Dict[str, str] = {}
|
||||
|
||||
def parse_num(token: str) -> Optional[int]:
|
||||
try:
|
||||
return int(token, 0)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
current_list: Optional[str] = None
|
||||
for raw_line in config_path.read_text(encoding='utf-8', errors='ignore').splitlines():
|
||||
line = raw_line.split('%', 1)[0].strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith('#beginEntryList'):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
current_list = parts[1]
|
||||
entry_lists[current_list] = []
|
||||
else:
|
||||
warnings.append('Malformed #beginEntryList line')
|
||||
continue
|
||||
if line.startswith('#endEntryList'):
|
||||
current_list = None
|
||||
continue
|
||||
if line.startswith('#diff'):
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
diff_name = parts[1]
|
||||
list_name = parts[2]
|
||||
diff_map[diff_name] = list_name
|
||||
else:
|
||||
warnings.append('Malformed #diff line')
|
||||
continue
|
||||
if line.startswith('#'):
|
||||
continue
|
||||
if current_list is None:
|
||||
continue
|
||||
left = line.split('|', 1)[0].strip()
|
||||
if not left:
|
||||
continue
|
||||
tokens = left.split()
|
||||
stage_id = parse_num(tokens[0])
|
||||
if stage_id is None:
|
||||
warnings.append(f'Invalid stage id in line: {raw_line}')
|
||||
continue
|
||||
time_override: Optional[int] = None
|
||||
if len(tokens) >= 2:
|
||||
time_val = parse_num(tokens[1])
|
||||
if time_val is None:
|
||||
warnings.append(f'Invalid time in line: {raw_line}')
|
||||
elif time_val != 3600:
|
||||
time_override = time_val
|
||||
entry_lists.setdefault(current_list, []).append((stage_id, time_override))
|
||||
|
||||
diff_name_map = {
|
||||
'Beginner': 'Beginner',
|
||||
'Advanced': 'Advanced',
|
||||
'Expert': 'Expert',
|
||||
'BeginnerExtra': 'Beginner Extra',
|
||||
'AdvancedExtra': 'Advanced Extra',
|
||||
'ExpertExtra': 'Expert Extra',
|
||||
'Master': 'Master',
|
||||
'MasterExtra': 'Master Extra',
|
||||
}
|
||||
|
||||
challenge_courses: Dict[str, List[Tuple[int, bool]]] = {}
|
||||
stage_time_overrides: Dict[int, int] = {}
|
||||
|
||||
for diff_name, list_name in diff_map.items():
|
||||
display_name = diff_name_map.get(diff_name)
|
||||
if not display_name:
|
||||
continue
|
||||
entries = entry_lists.get(list_name)
|
||||
if not entries:
|
||||
warnings.append(f'Missing entry list for {diff_name}: {list_name}')
|
||||
continue
|
||||
challenge_courses[display_name] = [(stage_id, False) for stage_id, _ in entries]
|
||||
for stage_id, time_override in entries:
|
||||
if time_override is None:
|
||||
continue
|
||||
existing = stage_time_overrides.get(stage_id)
|
||||
if existing is not None and existing != time_override:
|
||||
warnings.append(
|
||||
f'stage {stage_id} has conflicting time limits ({existing} vs {time_override})'
|
||||
)
|
||||
continue
|
||||
stage_time_overrides[stage_id] = time_override
|
||||
|
||||
if not challenge_courses:
|
||||
warnings.append('No challenge courses found in cmmod config.')
|
||||
|
||||
return challenge_courses, stage_time_overrides, warnings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description='Build SMB2 pack from extracted ROM.')
|
||||
parser.add_argument('--rom', type=Path, help='Path to extracted SMB2 ROM folder')
|
||||
@@ -727,18 +909,27 @@ def run_gui() -> None:
|
||||
lst_var = tk.StringVar()
|
||||
zip_var = tk.BooleanVar(value=True)
|
||||
|
||||
def browse_dir(target_var: tk.StringVar):
|
||||
value = filedialog.askdirectory()
|
||||
last_rom_dir = ''
|
||||
last_out_dir = ''
|
||||
|
||||
def browse_dir(target_var: tk.StringVar, last_dir_attr: str):
|
||||
nonlocal last_rom_dir, last_out_dir
|
||||
initial = last_rom_dir if last_dir_attr == 'rom' else last_out_dir
|
||||
value = filedialog.askdirectory(initialdir=initial or None)
|
||||
if value:
|
||||
target_var.set(value)
|
||||
if last_dir_attr == 'rom':
|
||||
last_rom_dir = value
|
||||
else:
|
||||
last_out_dir = value
|
||||
|
||||
ttk.Label(config_frame, text='ROM folder').grid(row=0, column=0, sticky=tk.W, padx=4, pady=4)
|
||||
ttk.Entry(config_frame, textvariable=rom_var, width=60).grid(row=0, column=1, sticky=tk.W, padx=4, pady=4)
|
||||
ttk.Button(config_frame, text='Browse', command=lambda: browse_dir(rom_var)).grid(row=0, column=2, padx=4, pady=4)
|
||||
ttk.Button(config_frame, text='Browse', command=lambda: browse_dir(rom_var, 'rom')).grid(row=0, column=2, padx=4, pady=4)
|
||||
|
||||
ttk.Label(config_frame, text='Output folder').grid(row=1, column=0, sticky=tk.W, padx=4, pady=4)
|
||||
ttk.Entry(config_frame, textvariable=out_var, width=60).grid(row=1, column=1, sticky=tk.W, padx=4, pady=4)
|
||||
ttk.Button(config_frame, text='Browse', command=lambda: browse_dir(out_var)).grid(row=1, column=2, padx=4, pady=4)
|
||||
ttk.Button(config_frame, text='Browse', command=lambda: browse_dir(out_var, 'out')).grid(row=1, column=2, padx=4, pady=4)
|
||||
|
||||
ttk.Label(config_frame, text='Pack ID').grid(row=2, column=0, sticky=tk.W, padx=4, pady=4)
|
||||
ttk.Entry(config_frame, textvariable=pack_id_var, width=30).grid(row=2, column=1, sticky=tk.W, padx=4, pady=4)
|
||||
@@ -759,6 +950,9 @@ def run_gui() -> None:
|
||||
courses_frame = ttk.Frame(frame)
|
||||
courses_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
load_controls = ttk.Frame(courses_frame)
|
||||
load_controls.pack(fill=tk.X, pady=(0, 8))
|
||||
|
||||
challenge_frame = ttk.LabelFrame(courses_frame, text='Challenge Courses', padding=10)
|
||||
story_frame = ttk.LabelFrame(courses_frame, text='Story Worlds', padding=10)
|
||||
challenge_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10))
|
||||
@@ -1023,6 +1217,69 @@ def run_gui() -> None:
|
||||
courses['story'] = story_worlds
|
||||
return courses
|
||||
|
||||
def load_from_rom_clicked():
|
||||
rom_path = Path(rom_var.get().strip())
|
||||
if not rom_path.exists():
|
||||
messagebox.showerror('Invalid ROM', 'ROM folder does not exist.')
|
||||
return
|
||||
if challenge_courses or story_worlds:
|
||||
if not messagebox.askyesno(
|
||||
'Replace courses',
|
||||
'Replace current course data with values from the ROM?',
|
||||
):
|
||||
return
|
||||
try:
|
||||
loaded_courses, loaded_worlds, loaded_overrides, load_warnings = (
|
||||
load_vanilla_courses_from_rom(rom_path)
|
||||
)
|
||||
except Exception as exc:
|
||||
messagebox.showerror('Load failed', str(exc))
|
||||
return
|
||||
challenge_courses.clear()
|
||||
challenge_courses.update(loaded_courses)
|
||||
story_worlds.clear()
|
||||
story_worlds.extend(loaded_worlds)
|
||||
stage_time_overrides.clear()
|
||||
stage_time_overrides.update(loaded_overrides)
|
||||
selected_course_name.set('')
|
||||
refresh_course_list()
|
||||
refresh_stage_list()
|
||||
refresh_world_list()
|
||||
refresh_world_stage_list()
|
||||
if load_warnings:
|
||||
messagebox.showwarning('Loaded with warnings', '\n'.join(load_warnings))
|
||||
|
||||
def load_from_cmmod_clicked():
|
||||
config_path_str = filedialog.askopenfilename(
|
||||
title='Select cmmod config',
|
||||
filetypes=[('Config files', '*.txt *.cfg'), ('All files', '*.*')],
|
||||
)
|
||||
if not config_path_str:
|
||||
return
|
||||
config_path = Path(config_path_str)
|
||||
if challenge_courses or story_worlds:
|
||||
if not messagebox.askyesno(
|
||||
'Replace courses',
|
||||
'Replace current challenge courses with values from the cmmod config?',
|
||||
):
|
||||
return
|
||||
try:
|
||||
loaded_courses, loaded_overrides, load_warnings = parse_cmmod_config(config_path)
|
||||
except Exception as exc:
|
||||
messagebox.showerror('Load failed', str(exc))
|
||||
return
|
||||
challenge_courses.clear()
|
||||
challenge_courses.update(loaded_courses)
|
||||
stage_time_overrides.clear()
|
||||
stage_time_overrides.update(loaded_overrides)
|
||||
selected_course_name.set('')
|
||||
refresh_course_list()
|
||||
refresh_stage_list()
|
||||
if story_worlds:
|
||||
load_warnings.append('Story worlds were left unchanged.')
|
||||
if load_warnings:
|
||||
messagebox.showwarning('Loaded with warnings', '\n'.join(load_warnings))
|
||||
|
||||
def build_pack_clicked():
|
||||
rom_path = Path(rom_var.get().strip())
|
||||
out_path = Path(out_var.get().strip())
|
||||
@@ -1061,6 +1318,7 @@ def run_gui() -> None:
|
||||
action_frame = ttk.Frame(frame)
|
||||
action_frame.pack(fill=tk.X, pady=(10, 0))
|
||||
ttk.Button(action_frame, text='Build Pack', command=build_pack_clicked).pack(side=tk.RIGHT)
|
||||
ttk.Button(load_controls, text='Load courses from cmmod config', command=load_from_cmmod_clicked).pack(side=tk.LEFT)
|
||||
|
||||
root.mainloop()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user