mirror of
https://github.com/freedoom/freedoom.git
synced 2025-08-29 14:16:55 -04:00
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:
parent
82aec99e71
commit
eeb52c5f1c
4 changed files with 702 additions and 247 deletions
37
Makefile
37
Makefile
|
@ -145,22 +145,45 @@ clean:
|
|||
$(MAKE) -C lumps/textures 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:
|
||||
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.
|
||||
test: test-map-names
|
||||
test: test-vanilla-compliance
|
||||
@echo
|
||||
@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:
|
||||
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
|
||||
# all 100 level WADs. Override the "NODE_BUILDER" prefixed variables to
|
||||
|
|
|
@ -162,6 +162,11 @@ It is sensible to also heed the following guidelines:
|
|||
is an engine with strict adherence to vanilla Doom limits and
|
||||
bugs, and working in it assures that levels can be played with any
|
||||
_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
|
||||
|
||||
|
|
|
@ -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
667
scripts/test-vanilla-compliance
Executable 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()
|
Loading…
Add table
Add a link
Reference in a new issue