build: Add test-vanilla-compliance

The test-vanilla-compliance build target (also invoked by the "test"
build target) and script can be used to test the level WAD files for
vanilla compliance. Also, a subset of the errors found can be fixed by
the "fix" build target. To summarize:
  make test                     # Run all tests
  make test-vanilla-compliance  # Run test-vanilla-compliance
  make fix                      # Run all fixes
  make fix-vanilla-compliance   # Run test-vanilla-compliance -f
This commit is contained in:
Steven Elliott 2021-06-05 17:55:41 -04:00
parent 82aec99e71
commit eeb52c5f1c
4 changed files with 702 additions and 247 deletions

View file

@ -145,22 +145,45 @@ clean:
$(MAKE) -C lumps/textures clean $(MAKE) -C lumps/textures clean
$(MAKE) -C manual clean $(MAKE) -C manual clean
# Test targets all of which are a dependency of "test". # Test targets some of which are a dependency of "test".
# Test that WAD files have the expected map names. # Test that the level WAD files have the expected map names.
test-map-names: test-map-names:
scripts/fix-map-names -t levels scripts/test-vanilla-compliance -n levels
# Test that the level WAD files have vanilla compliance. This is a superset of
# the "test-map-names" build target.
test-vanilla-compliance:
scripts/test-vanilla-compliance levels
# Run all tests. Add test-* targets above, and then as a dependency here. # Run all tests. Add test-* targets above, and then as a dependency here.
test: test-map-names test: test-vanilla-compliance
@echo @echo
@echo "All tests passed." @echo "All tests passed."
# Non-test targets that run scripts in the "scripts" directory. # Fix targets some of which are a dependency of "fix".
# Fix the map names. # Fix the level WAD files so that they have the expected map names.
fix-map-names: fix-map-names:
scripts/fix-map-names levels scripts/test-vanilla-compliance -fn levels
# Fix the level WAD files so that they have vanilla compliance. This is a
# superset of the "fix-map-names" build target.
fix-vanilla-compliance:
scripts/test-vanilla-compliance -f levels
# TODO: I'm not sure we want to run this routinely, but I thought I'd put it
# here for completeness. Currently it makes a lot of changes to buildcfg.txt
# that don't have an obvious impact. Consequently "fix" does not depend on this
# target. Just delete this TODO and target if we don't want this. Maybe add a
# proper description in any case.
fix-gfx-offsets:
scripts/fix-gfx-offsets sprites/*.png
# Run all fixes. Add fix-* targets above, and then as a dependency here.
fix: fix-vanilla-compliance
@echo
@echo "All fixable errors fixed."
# Rebuild the nodes for the level WADs. By default this invokes "ZenNode" on # Rebuild the nodes for the level WADs. By default this invokes "ZenNode" on
# all 100 level WADs. Override the "NODE_BUILDER" prefixed variables to # all 100 level WADs. Override the "NODE_BUILDER" prefixed variables to

View file

@ -162,6 +162,11 @@ It is sensible to also heed the following guidelines:
is an engine with strict adherence to vanilla Doom limits and is an engine with strict adherence to vanilla Doom limits and
bugs, and working in it assures that levels can be played with any bugs, and working in it assures that levels can be played with any
_Doom_ engine. _Doom_ engine.
* Use a Doom editor to check for errors. In
http://eureka-editor.sourceforge.net/[Eureka] it's possible to
check for errors with the Check / All menu, or by pressing `F9`.
* If possible run `make test` and fix any errors found. Note that
some of the errors can be fixed by `make fix`.
=== Graphics === Graphics

View file

@ -1,240 +0,0 @@
#!/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()

667
scripts/test-vanilla-compliance Executable file
View file

@ -0,0 +1,667 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: BSD-3-Clause
#
# test-vanilla-compliance - Test for vanilla compliance
#
# This tests for vanilla compliance, that for levels specified the lump names,
# attributes of things, linedefs and sectors are as expected for an ordinary,
# or vanilla, level.
#
# The levels may be specified by passing any number of directories that
# contain WAD files, or the WAD files themselves.
#
# Since this script replaces the now defunct "fix-map-names" that functionality
# can be accessed via options "-n" (name-only), and "-f" (fix instead of
# test).
#
# The output, which is a bit different than "fix-map-names", is designed to be
# easy to parse. For example, grep for "^THINGS MAP10" to see errors with
# things in MAP10. The columns are space separated. The errors or fixes are
# listed after "ERRORS:" and "FIXES:" respectively.
#
# Normally this script is invoked by "make":
# make test # Test all of the levels
# make test-map-names # Test only the map names (first zero byte lump)
# make fix # Fix all fixable errors in all levels
# make fix-map-names # Fix only the map names (first zero byte lump)
# Imports
import argparse
import os
import re
import struct
import sys
# Globals. Alphabetical.
# Command line arguments.
args = {}
# A type that is harmless / invisible to be deleted later. This is the Doom
# Builder camera.
benign_type = 32000 # Doom Builder camera
# The Doom version. 0 for unknown, 1 for Doom 1 and 2 for Doom 2.
doom_version = 0
# The WAD file currently being processed.
current_wad = ""
# The directory count read from the header.
dir_count = 0
# The following option_* variables can be used to turn on additional checks for
# a stricter vanilla, but they are not required for Freedoom.
# If true then test that only the lower 9 bits of linedef flags are set.
option_linedef_flags = False
# If true then test that the angle is a multiple of 45 degrees.
option_thing_angle = False
# If true then test that only the lower 5 bits of spawn flags are set.
option_thing_spawn_flags = False
# Convert from Freedoom 1 names to Doom 1 names. This isn't needed for modern
# Freedoom, but it allows this script to work on older Freedoom.
freedoom_1_re = re.compile(r"^C(\d)M(\d)$")
# Convert from Freedoom death map (dm*.wad) names to Doom 2 names.
freedoom_dm_re = re.compile(r"^DM(\d\d)$")
# Test WAD files that are to be ignored.
ignored_wads = set(["dummy.wad", "test_levels.wad"])
# Whether the current map is allowed to have secret exits (whether it is listed
# in may_have_secret_exit).
is_may_have_secret_exit = False
# A regular expression that matches name lumps (the first zero byte lump such
# as "E1M1" or "MAP01").
map_name_re = re.compile(r"^((E\dM\d)|(MAP\d\d))$")
# The number of WADs that have been processed. This should be the total number
# of WADs specified minus the ignored_wads.
processed_wad_count = 0
# Keeps track of whether the header for the map name and WAD combination has
# been shown.
region_header_shown = {}
# Information per region. A region is a contiguous portion of the WAD file
# with some repeating structure. Usually it's a lump, but it can also be the
# directory.
region_info = {"THINGS" : ("<2h3H",
("X", "Y", "Angle", "Type", "SFs"),
"%5d %5d %5d %5d %5d"),
"LINEDEFS" : ("<7H",
("BVert", "EVert", "Flags", "LineT", "STag",
"RSDef", "LSDef"),
"%5d %5d %5d %5d %5d %5d %5d"),
"SECTORS" : ("<2h8s8sh2H",
("FHght", "CHght", "FText", "CText", "Light",
"Type", "Tag"),
"%5d %5d %8s %8s %5d %5d %5d"),
"directory": ("<2i8s",
("Offset", "Size", "Name"),
"%8d %8d %8s")}
# The standard lump names, in order, as they are to appear in vanilla PWAD
# level files that don't contain extra resources (textures, etc.). This assumes
# a node builder has been used.
standard_lump_names = ("<map name>", "THINGS", "LINEDEFS", "SIDEDEFS",
"VERTEXES", "SEGS", "SSECTORS", "NODES", "SECTORS", "REJECT",
"BLOCKMAP")
# The expected number of lumps.
standard_lump_names_len = len(standard_lump_names)
# Used to phrase message correctly. Future is used for test mode, and past for
# fix mode.
tense = ""
# The total number of errors or fixes returned by do_* methods.
total_do_count = 0
# A count of unexpected errors. These are errors other than what this script
# intends to check for, such as the file not being readable. These errors
# suggest that something is fundamentally wrong, and that the results should
# not be trusted.
unexpected_count = 0
# Maps that can have secret exits. A secret exit in maps other than these is
# an error.
may_have_secret_exit = { "E1M3", "E2M5", "E3M6", "E4M2", "MAP15", "MAP31" }
# Number of WADs that had errors or fixes seen as indicated by the do_* methods.
wad_error_fix_count = 0
# Things. Specifies what is allowed for things.
# Allowed values for Ultimate Doom (Doom 1 in doom.wad) from this list of
# editor numbers. See
# https://zdoom.org/wiki/Standard_editor_numbers
allowed_things_doom = {
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21,
22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59,
60, 61, 62, 63, 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2010, 2011,
2012, 2013, 2014, 2015, 2018, 2019, 2022, 2023, 2024, 2025, 2026, 2028,
2035, 2045, 2046, 2047, 2048, 2049, 3001, 3002, 3003, 3004, 3005, 3006 }
# Doom 2 editor numbers are Doom 1 (the above) with additional Doom 2 monsters.
allowed_things_doom2 = allowed_things_doom.union(set(range(64, 90)))
# Allowed things as a function of the doom version.
allowed_things_all = { 1: allowed_things_doom, 2: allowed_things_doom2 }
# A reference to the current allowed things in allowed_things_all. It's set
# with each map change.
allowed_things = None
# A mask of the allowed spawn flag bits. See
# https://zdoom.org/wiki/Thing#Spawn_flags
allowed_things_spawn_flags = 0x1f # [0, 32)
# Linedefs. Specifies what is allowed for linedefs.
# A mask of the allowed linedef flag bits. See
# https://zdoom.org/wiki/Linedef#Linedef_flags
allowed_linedefs_flags = 0x1ff # [0, 512)
# The allowed linedef types. This is the "Reg", meaning regular, linedef types
# taken from this documentation:
# https://soulsphere.org/projects/boomref/boomref.txt
allowed_linedefs_types = set(range(142)) # Exclude 78?
# The subset of allowed_linedefs_types that leads to secret maps.
allowed_linedefs_types_to_secret = { 51, 124 }
# Sectors. Specifies what is allowed for sectors.
# The allowed sector types. See
# https://doomwiki.org/wiki/Sector#Doom
allowed_sectors_types = { 0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17 }
# Classes
# The error exception class for this script.
class TestException(Exception):
pass
# Functions. Alphabetical.
# The following do_* methods take action (test or fix). For them the term "region"
# means a contiguous set of bytes in a WAD file that consists of some structure,
# and "item" means one instance of that repeated structure.
# Process a directory item.
def do_directory(messages, number, offset, size, name):
# We don't have an expectation for lump names for IWADs. For --name-only
# only process the first lump (the name lump at number == 0).
first_lump_only = args.name_only or args.fix
if (not args.iwad) and ((not first_lump_only) or (number == 0)):
fix_name = get_expected_lump_name(number)
if fix_name and (name != fix_name):
orig_name = name
if number == 0:
fixable = ", but " + tense + " fixed with " + fix_name
if args.fix:
name = fix_name
allowed = " is not allowed"
else:
fixable = " and can not be fixed"
allowed = " is at the wrong index" if name in standard_lump_names \
else " is not allowed"
messages.append("Lump name %s%s%s." % (orig_name, allowed, fixable))
# It's not clear where to put this error message that concerns the
# entire directory (not just one entry), so add it to the first entry.
if (not first_lump_only) and (number == 0) and (
dir_count != standard_lump_names_len):
messages.append("Expected %d lumps, but found %d lumps." % (
standard_lump_names_len, dir_count))
return (offset, size, name)
# Process a LINEDEFS item.
def do_linedef(messages, number, bvert, evert, flags, linet, stag, rsdef, lsdef):
# Flag bit 0 through 8 are allowed.
if option_linedef_flags:
fix_flags = allowed_linedefs_flags & flags
if flags != fix_flags:
messages.append("Flags %d is not allowed, but %s fixed with %d." % (
flags, tense, fix_flags))
if args.fix:
flags = fix_flags
# There's no obvious automated way of fixing line types.
if (linet not in allowed_linedefs_types) and not args.fix:
messages.append("Type %s is not allowed and can not be fixed." % linet)
# Only allow secret exits on the correct maps since that behavior is hard
# coded in the engine. A possible fix would be to map to the equivalent
# non-secret exit type, but it's probably better to evaluate it and replace
# it in order to avoid multiple exits.
if (linet in allowed_linedefs_types_to_secret) and not is_may_have_secret_exit:
messages.append("Type %s is a secret exit, but secret exits are only "
"allowed on maps %s. Not fixable." % (
linet, may_have_secret_exit))
return (bvert, evert, flags, linet, stag, rsdef, lsdef)
# Process (do) a region. A region can be either a lump, or some other non-lump
# array of structures (the directory). Note that unlike the other do_*
# methods this method process the entire region, not just one item in it.
def do_region(region_name, map_name, wad, fhand, lump_offset, lump_size):
info = region_info.get(region_name)
if info is None:
# Nothing is supported for this region type.
return 0
old_pos = fhand.tell()
fhand.seek(lump_offset)
unpack_format, field_names_part, row_format_part, do_item = info
unpack_size = struct.calcsize(unpack_format)
field_names = ("Region", "Map", "Num") + field_names_part + (
" Fixes" if args.fix else " Errors",)
row_format = "%" + str(len(region_name)) + "s %5s %5d " + \
row_format_part + "%s"
header_format = row_format.replace("d", "s")
header = header_format % field_names
count = 0 # Count of errors or fixes.
offset = 0
num = 0
first = len(region_header_shown) == 0
while offset < lump_size:
bytes = fhand.read(unpack_size)
fields = wad_unpack(unpack_format, bytes)
messages = []
fields = do_item(messages, num, *fields)
if args.fix and len(messages) > 0:
fhand.seek(fhand.tell() - unpack_size)
bytes = wad_pack(unpack_format, *fields)
fhand.write(bytes)
if args.verbose or len(messages) > 0:
if region_name not in region_header_shown:
region_header_shown[region_name] = set()
shown = region_header_shown[region_name]
if wad not in shown:
if not first:
print("")
print("Process %s for %s in %s" % (region_name, map_name, wad))
print(header)
shown.add(wad)
row = (region_name, map_name, num) + fields + (
get_combined_message(messages),)
print(row_format % row)
count += len(messages)
offset += unpack_size
num += 1
fhand.seek(old_pos)
return count
# Process a SECTORS item.
def do_sector(messages, number, fhght, chght, ftext, ctext, light, typ, tag):
# There's no obvious automated way of fixing sector types.
if (typ not in allowed_sectors_types) and not args.fix:
messages.append("Type %s is not allowed and can not be fixed." % typ)
return (fhght, chght, ftext, ctext, light, typ, tag)
# Process a THINGS item.
def do_thing(messages, number, x, y, angle, typ, spawn_flags):
# In the following sections each check one field within the thing. They are
# in file order.
# Make sure that the angle is in range [0, 360) and that it is a
# multiple of 45. The "//" rounds down, so the " + 22" makes it round
# off to the nearing multiple of 45. The "% 360" gets it to range [0, 360).
if option_thing_angle:
fix_angle = (45 * ((angle + 22) // 45)) % 360
if angle != fix_angle:
messages.append("Angle %d is not allowed, but %s fixed with %d." % (
angle, tense, fix_angle))
if args.fix:
angle = fix_angle
# Make sure the thing type is allowed for the Doom version.
if typ not in allowed_things:
prefix = "Type %d is not allowed for Doom %s, but " % (
typ, doom_version)
if typ == benign_type:
# Don't fix if already benign.
if not args.fix:
messages.append("%sis already the benign type, so it will not "
"be fixed." % prefix)
else:
messages.append("%s%s fixed with begin type %d." % (
prefix, tense, benign_type))
if args.fix:
# This seems to be a benign and non-visible type, so it's not a full
# fix (since proper deleting is hard) but this makes it easier to
# find all the offending things by searching for one type.
# (benign_type).
typ = benign_type
# Make sure the spawn flags limited to the lowest 5 bits.
if option_thing_spawn_flags:
fix_spawn_flags = 0x1f & spawn_flags
if spawn_flags != fix_spawn_flags:
messages.append("Spawn flags %d is not allowed, but %s fixed with "
"%d." % (spawn_flags, tense, fix_spawn_flags))
if args.fix:
spawn_flags = fix_spawn_flags
return (x, y, angle, typ, spawn_flags)
# Combine a list of messages by comma separating them, and and then prefixing
# with " ERRORS: " or " FIXES: " depending on the mode.
def get_combined_message(messages):
if len(messages) == 0:
return ""
return (" FIXES: " if args.fix else " ERRORS: " ) + ", ".join(messages)
# Get the doom version. 1 for Doom 1 or 2 for Doom 2
def get_doom_version(map_name):
return 2 if map_name.startswith("MAP") else 1
# Get the expected lump name at a specified index.
def get_expected_lump_name(index):
if index == 0:
# Strip of the directory, upper case, remove ".wad".
name = os.path.basename(current_wad).upper()
if name.endswith(".WAD"):
name = name[:-4]
# For IWADS guess that the first name should be the first map.
if args.iwad and ("DOOM" in name):
return "MAP01" if ("2" in name) else "E1M1"
# 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
elif index < standard_lump_names_len:
return standard_lump_names[index]
else:
return None
# Update globals that depend on the map name. This is called for each map
# change.
def handle_map_change(map_name):
global doom_version
global allowed_things
global is_may_have_secret_exit
new_doom_version = get_doom_version(map_name)
if new_doom_version != doom_version:
doom_version = new_doom_version
allowed_things = allowed_things_all[doom_version]
is_may_have_secret_exit = map_name in may_have_secret_exit
# Initialization that can't be done in global variables.
def init():
global tense
# This is done here beacuse the function names are not yet visible in the
# global variables.
region_info["THINGS"] += (do_thing,)
region_info["LINEDEFS"] += (do_linedef,)
region_info["SECTORS"] += (do_sector,)
region_info["directory"] += (do_directory,)
# Tense as in "was" for the past and "can be" for the future.
tense = "was" if args.fix else "can be"
# It's fine if this is incorrect for Doom 1. It will be overridden when
# the first map is seen.
handle_map_change("MAP01")
# Main enty point.
def main():
parse_args()
init()
process_paths()
summarize()
# 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",
"--fix",
action="store_true",
help="Fix. Fix a subset of the errors.")
parser.add_argument(
"-l",
"--follow-symlinks",
action="store_true",
help="Follow symbolic links (dangerous).")
parser.add_argument(
"-i",
"--iwad",
action="store_true",
help="IWAD. Allow processing of IWADs in addition to PWADs. This is " +
"mostly for internal use. When combined with verbose (-v) it can " +
"be used to generate lists of allowed values.")
parser.add_argument(
"-n",
"--name-only",
action="store_true",
help="Only consider the map name (first lump), and nothing else.")
parser.add_argument(
"-r",
"--recursive",
action="store_true",
help="Recurse into sub-directories.")
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Verbose. Output every item processed.")
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):
global current_wad
if os.path.islink(path) and not args.follow_symlinks:
# Symlink. Avoid following by default in case it points somewhere it
# shouldn't.
print("Ignoring symlink \"%s\". Use -l to follow symlinks." % path)
elif 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(os.path.join(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"):
current_wad = path
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 wad_error_fix_count
global dir_count
global processed_wad_count
global total_do_count
global unexpected_count
if os.path.basename(wad).lower() in ignored_wads:
# A known WAD that should not be processed.
return
# This is after the above since it is only a count of WADs that were
# processed.
processed_wad_count += 1
try:
wad_do_count = 0 # The number of errors or fixes.
map_name = "???" # Used for logging. May be "???" for IWADs.
with open(wad, "r+b" if args.fix else "rb") as fhand:
magic, dir_count, dir_offset = wad_unpack("<4s2i",
fhand.read(12))
is_iwad = magic == "IWAD"
if not (magic == "PWAD" or (args.iwad and is_iwad)):
raise TestException("Not an allowed Doom file header magic. "
"magic=" + magic)
# Directory at offset 0x8 in the header.
fhand.seek(dir_offset)
lump_index = 0 # zero based
# Process the lumps. For --name-only only the first lump needs to
# be processed in order to get the map name.
if args.name_only:
lump_index_limit = 1
else:
lump_index_limit = dir_count
while lump_index < lump_index_limit:
# The first lump in the directory, which should be the 0 byte map
# name one.
lump_offset, lump_size, lump_name = wad_unpack("<2i8s",
fhand.read(16))
# Get the map name both so that the Doom version can be
# determined, and for logging.
if is_iwad:
# Limited error checking, and no fixing, for IWADs.
name_lump = map_name_re.match(lump_name)
else:
# A particular order and sizes are expected for levels.
name_lump = lump_index == 0
if name_lump:
map_name = lump_name
# This tells us what items are allowed.
if name_lump and not args.name_only:
handle_map_change(map_name)
# Verify the contents of lumps.
wad_do_count += do_region(lump_name, map_name, wad, fhand,
lump_offset, lump_size)
lump_index += 1
# Consider the directory, which is traditionally after all of the
# lumps, now that the lumps have been processed. "16" is the size
# of each directory entry.
wad_do_count += do_region("directory", map_name, wad, fhand,
dir_offset, dir_count * 16)
except IOError as err:
# Probably the WAD file couldn't be open for read (test) or read and
# write (default).
print("Unable to open for read%s: %s: " % (
" and write" if args.fix else "", err))
unexpected_count += 1
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.
print("WAD file %s is too small: %s" % (wad, err))
unexpected_count += 1
except TestException as err:
print(str(err))
unexpected_count += 1
if wad_do_count > 0 or args.verbose:
print("\nFilename for map %s in the above is %s. It has %d %s" % (
map_name, wad, wad_do_count, "fixes." if args.fix else "errors."))
if wad_do_count > 0:
total_do_count += wad_do_count
wad_error_fix_count += 1
# Summarize what happened, and then exit with the appropriate exit code.
def summarize():
no_fixes = "did not have fixable errors" if args.fix else "were correct"
end = "fixes." if args.fix else "errors."
if wad_error_fix_count == 0:
# A bit confusing in the fix case since only fixes that can
# actually be done are counted, but hopefully people will figure it out.
print("\nAll %d WADs %s. There are no %s" % (
processed_wad_count, no_fixes, end))
else:
print("\n%d of %d WAD files have %s There are a total of %d %s" % (
wad_error_fix_count, processed_wad_count, end, total_do_count, end))
if unexpected_count > 0:
print("There were %d unexpected errors, so the result may not be "
"valid." % unexpected_count)
sys.exit(1 if (unexpected_count or (total_do_count and not args.fix)) else 0)
# A version of wad_pack() suitable for WAD files. Convert strings to bytes
# before packing.
def wad_pack(format, *fields):
if "s" in format:
new_fields = []
for field in fields:
new_fields.append(field.encode("UTF-8") if type(field) == str else field)
else:
new_fields = fields
return struct.pack(format, *new_fields)
# A version of wad_unpack() suitable for WAD files. Convert strings to UTF-8
# (superset of ASCII), and strip off any trailing nulls.
def wad_unpack(format, buffer):
raw_fields = struct.unpack(format, buffer)
if "s" in format:
fields = []
for field in raw_fields:
# Split by null and take the first non-null result in order to
# strip trailing nulls.
fields.append(field.decode("UTF-8").partition("\0")[0]
if type(field) == bytes else field)
return tuple(fields)
else:
return raw_fields
# So that this script may be accessed as a module.
if __name__ == "__main__":
main()