Python 2 is very near end-of-life, and Python3-compatible changes to a few scripts introduced compatibility problems with 2.7 again. It went unnoticed for me since my system symlinks "python" to "python3", but it broke the build on systems where that symlink is still python2. At this point in time, I feel it is worth targetting modern Python and forgetting about 2.7.
241 lines
7.4 KiB
Python
Executable File
241 lines
7.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: BSD-3-Clause
|
|
#
|
|
# fix-map-names - Fix map names in WAD files
|
|
#
|
|
# Fix the map name, which is the name of the first lump. If the "-t" option is
|
|
# passed for test mode then incorrect map names are displayed, but not fixed.
|
|
#
|
|
# This script can be invoked with make target "fix-map-names" (no "-t" option)
|
|
# or make target "test-map-names" ("-t" option). Make target "test"
|
|
# ("-t" option) will run this and any other test.
|
|
|
|
# Imports
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import struct
|
|
import sys
|
|
|
|
# Globals
|
|
|
|
args = {} # Command line arguments.
|
|
error_count = 0
|
|
fixes_needed = 0
|
|
freedoom_1_re = re.compile(r"^C(\d)M(\d)$") # FD #1 maps
|
|
freedoom_dm_re = re.compile(r"^DM(\d\d)$") # FD DM maps
|
|
header_shown = False
|
|
ignored_wads = set(["dummy.wad", "test_levels.wad"])
|
|
last_error = None
|
|
map_name_re = re.compile(r"^((E\dM\d)|(MAP\d\d))$")
|
|
output_line = "%-17s %-9s %-7s %s"
|
|
|
|
# Functions
|
|
|
|
# Handle error 'msg'. Pass None to reset 'last_error'.
|
|
def error(msg):
|
|
global error_count
|
|
global last_error
|
|
|
|
last_error = msg
|
|
if msg:
|
|
error_count += 1
|
|
|
|
|
|
# Given WAD path 'wad' return the expected map name as a function of the
|
|
# filename.
|
|
def get_expected_map_name(wad):
|
|
# Strip of the directory, upper case, remove ".wad".
|
|
name = os.path.basename(wad).upper()
|
|
if name.endswith(".WAD"):
|
|
name = name[:-4]
|
|
|
|
# Convert from Freedoom name to Doom names.
|
|
name = freedoom_1_re.sub(r"E\1M\2", name)
|
|
name = freedoom_dm_re.sub(r"MAP\1", name)
|
|
|
|
if map_name_re.match(name):
|
|
return name
|
|
else:
|
|
return None
|
|
|
|
|
|
# Parse the command line arguments and store the result in 'args'.
|
|
def parse_args():
|
|
global args
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Fix map names in WAD files.",
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
)
|
|
|
|
# The following is sorted by long argument.
|
|
|
|
parser.add_argument(
|
|
"-f",
|
|
"--force",
|
|
action="store_true",
|
|
help="Force. Fix map name regardless of the existing map name.",
|
|
)
|
|
parser.add_argument(
|
|
"-q", "--quiet", action="store_true", help="Quiet (minimum output)."
|
|
)
|
|
parser.add_argument(
|
|
"-r",
|
|
"--recursive",
|
|
action="store_true",
|
|
help="Recurse into directories.",
|
|
)
|
|
parser.add_argument(
|
|
"-t",
|
|
"--test",
|
|
action="store_true",
|
|
help="Test mode. Don't make any changes.",
|
|
)
|
|
parser.add_argument(
|
|
"paths",
|
|
metavar="PATH",
|
|
nargs="+",
|
|
help="WAD paths, files and directories.",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
return args
|
|
|
|
|
|
# Process path 'path' which is at depth 'depth'. If 'depth' is 0 then this is
|
|
# a top level path passed in on the command line.
|
|
def process_path(path, depth):
|
|
if os.path.isdir(path):
|
|
# Directory. If not recursive then only consider this directory if it
|
|
# was specified explicitly.
|
|
if args.recursive or not depth:
|
|
path_list = os.listdir(path)
|
|
path_list.sort()
|
|
for base in path_list:
|
|
process_path(path + "/" + base, depth + 1)
|
|
else:
|
|
# File. Only process WAD files that were specified explicitly
|
|
# (depth 0), or that have the expected suffix.
|
|
if (not depth) or path.lower().endswith(".wad"):
|
|
process_wad(path)
|
|
|
|
|
|
# Process the paths passed in on the command line.
|
|
def process_paths():
|
|
for path in args.paths:
|
|
process_path(path, 0)
|
|
|
|
|
|
# Process WAD path 'wad'.
|
|
def process_wad(wad):
|
|
global header_shown
|
|
global last_error
|
|
global fixes_needed
|
|
|
|
if os.path.basename(wad).lower() in ignored_wads:
|
|
# A known WAD that should not be processed.
|
|
return
|
|
|
|
try:
|
|
# Reset everything.
|
|
error(None)
|
|
lump_name = None
|
|
fix_needed = False
|
|
|
|
expected_name = get_expected_map_name(wad)
|
|
if not expected_name:
|
|
raise Exception("Unable to get the expected name")
|
|
with open(wad, "rb" if args.test else "r+b") as fhand:
|
|
magic = fhand.read(4)
|
|
if not isinstance(magic, str):
|
|
# magic is bytes in Python 3.
|
|
magic = magic.decode("UTF-8")
|
|
if not magic == "PWAD":
|
|
raise Exception("Not a PWAD. magic=" + magic)
|
|
# Directory at offset 0x8 in the header.
|
|
fhand.seek(0x08)
|
|
directory_offset, = struct.unpack("<I", fhand.read(4))
|
|
fhand.seek(directory_offset)
|
|
# The first lump in the directory, which should be the 0 byte map
|
|
# name one.
|
|
lump_data_offset, lump_size, lump_name = struct.unpack(
|
|
"<II8s", fhand.read(16)
|
|
)
|
|
if not isinstance(lump_name, str):
|
|
# lump_name is bytes in Python 3.
|
|
lump_name = lump_name.decode("UTF-8")
|
|
# Get rid of the null suffix.
|
|
lump_name = lump_name.partition("\0")[0]
|
|
if lump_size:
|
|
# The first lump should be 0 bytes.
|
|
error(
|
|
"First lump size non-zero with "
|
|
+ str(lump_size)
|
|
+ " bytes"
|
|
)
|
|
elif not (args.force or map_name_re.match(lump_name)):
|
|
# A sanity check to make sure we read the right part.
|
|
error("Actual name unexpected")
|
|
elif expected_name != lump_name:
|
|
# The name is not what we thought it should be.
|
|
fix_needed = True
|
|
fixes_needed += 1
|
|
if fix_needed and not args.test:
|
|
# Seek to the lump name and the overwrite the lump name with
|
|
# the expected name.
|
|
fhand.seek(directory_offset + 8)
|
|
fhand.write(struct.pack("8s", expected_name.encode("UTF-8")))
|
|
except IOError as err:
|
|
# Probably the WAD file couldn't be open for read (test) or read and
|
|
# write (default).
|
|
error(
|
|
"Unable to open for read"
|
|
+ ("" if args.test else " and write")
|
|
+ ": "
|
|
+ str(err)
|
|
)
|
|
except struct.error as err:
|
|
# This is probably the reason since seek silently succeeds even when
|
|
# the location is not possible, but then unpack fails due to the short
|
|
# read.
|
|
error("File too small: " + str(err))
|
|
except Exception as err:
|
|
# This was probably explicitly thrown by this script.
|
|
error(str(err))
|
|
|
|
if (last_error or fix_needed) and not args.quiet:
|
|
# Map None to "".
|
|
expected_name, lump_name, last_error = [
|
|
x if x else "" for x in (expected_name, lump_name, last_error)
|
|
]
|
|
if not header_shown:
|
|
print(output_line % ("WAD", "Expected", "Actual", "Error"))
|
|
print(output_line % ("---", "--------", "------", "-----"))
|
|
header_shown = True
|
|
print(output_line % (wad, expected_name, lump_name, last_error))
|
|
|
|
|
|
# Summarize what happened, and then exit with the appropriate exit code.
|
|
def summarize():
|
|
if not args.quiet:
|
|
if fixes_needed:
|
|
print(
|
|
"\n%s %d WADs with the incorrect map name."
|
|
% ("Found" if args.test else "Fixed", fixes_needed)
|
|
)
|
|
else:
|
|
print("\nAll WADs had the correct map name.")
|
|
if error_count:
|
|
print("There were %d errors." % error_count)
|
|
sys.exit(1 if (error_count or (args.test and fixes_needed)) else 0)
|
|
|
|
|
|
# Main
|
|
|
|
parse_args()
|
|
process_paths()
|
|
summarize()
|