freedoom/lumps/textures/build-textures
Mike Swanson 8835afef5f Copyright date bump and apply SPDX tags.
The tags are shorthand for the license of each file and avoid
copying the full license text into each one (and avoids having
to manually update the dates in each one...).
2017-02-15 16:41:53 -08:00

343 lines
9.6 KiB
Python
Executable file

#!/usr/bin/env python
# SPDX-License-Identifier: BSD-3-Clause
#
# Texture lump builder for Freedoom
#
# This script builds the TEXTURE1, TEXTURE2 and PNAMES lumps for
# Freedoom - the lumps containing the texture definitions.
#
# Freedoom does not use deutex's built-in texture builder for several
# reasons:
#
# Firstly, Freedoom's texture lumps need to be compatible with those
# of the original Doom WADs. There are several examples of PWADs that
# replace only PNAMES, or only TEXTURE1. Because the TEXTURE1 format
# is tightly coupled to the ordering of the PNAMES lump, this means
# that these WADs will fail in Freedoom unless the ordering is
# strictly maintained. For example, if entry #3 in PNAMES is DOOR2_4,
# it must also be the same in Freedoom's version of PNAMES.
#
# Freedoom has a single configuration file where all its textures are
# defined, but doom.wad contains two separate lumps: TEXTURE1/TEXTURE2.
# Similarly to the first problem, it's important that the compatible
# lumps are built with the same textures in each: some WADs replace
# TEXTURE1 but not TEXTURE2, with the result that many textures end
# up missing.
#
# Finally, deutex does not allow a filename to be specified for
# TEXTURE entries. That is to say, you can't do this:
#
# [textures]
# TEXTURE1 = fdtxtr1.txt
#
# This is an annoying limitation that means that the different
# Freedoom IWADs cannot be built in parallel by make.
import collections
import re
import sys
import struct
COMMENT_RE = re.compile(r"\s*;.*")
TEXTURE_NAME_RE = re.compile(r"\s*([\w-]+)\s+(-?\d+)\s+(-?\d+)")
PATCH_NAME_RE = re.compile(r"\s*\*\s+([\w-]+)\s+(-?\d+)\s+(-?\d+)")
Texture = collections.namedtuple("Texture", ["w", "h", "patches"])
TexturePatch = collections.namedtuple("TexturePatch", ["pname", "x", "y"])
class TextureSet(collections.OrderedDict):
def __init__(self, pnames):
"""Initialize a new set of textures.
Args:
pnames: List of PNAMES to use for the textures. New
patches will be added to this list as the texture
set is extended.
"""
super(TextureSet, self).__init__()
self.pnames = pnames
def pname_index(self, pname):
"""Get the index into the PNAMES list for the given patch.
If the patch is not in the list, it will be added to the
list.
Args:
pname: Name of the patch to look up.
Returns:
Index into the PNAMES list where this patch can be found.
"""
pname = pname.upper()
try:
return self.pnames.index(pname)
except ValueError:
self.pnames.append(pname)
return len(self.pnames) - 1
def add_texture(self, name, width=0, height=0):
"""Add a new texture to the set.
If a texture is already defined with the given name, the
current definition is erased (though the ordering of
textures is maintained).
Args:
name: Name of the texture.
width: Width of the texture in pixels.
height: Height of the texture in pixels.
"""
name = name.upper()
self[name] = Texture(width, height, [])
def add_texture_patch(self, txname, patch, x, y):
"""Add a patch to the given texture.
Args:
txname: Name of the texture.
patch: Name of the patch to add.
x: X offset for the patch in pixels.
y: Y offset for the patch in pixels.
"""
txname = txname.upper()
texture = self[txname]
tp = TexturePatch(self.pname_index(patch), x, y)
texture.patches.append(tp)
def write_texture_lump(self, filename):
"""Build the texture lump and output to a file.
Args:
filename: Path to file in which to store the resulting
lump.
"""
with open(filename, "wb") as out:
# Header indicating number of textures:
out.write(struct.pack("<l", len(self)))
# Offsets:
offset = 4 + 4 * len(self)
for _, texture in self.items():
out.write(struct.pack("<l", offset))
offset += 22 + 10 * len(texture.patches)
# Write actual texture data:
for name, texture in self.items():
maptexture = struct.pack("<8slhhlh",
name.encode("ascii"), 0,
texture.w, texture.h,
0, len(texture.patches))
out.write(maptexture)
# Patches:
for patch in texture.patches:
mappatch = struct.pack("<hhhhh",
patch.x, patch.y, patch.pname,
0, 0)
out.write(mappatch)
class NamesFileError(Exception):
pass
def read_names_file(filename):
"""Read a list of names from a text file.
The names are a maximum of 8 characters long.
Args:
filename: Name of the file from which to read names.
Returns:
List of name strings, all in upper case.
"""
with open(filename) as f:
result = []
for line in f:
line = COMMENT_RE.sub('', line).strip()
if len(line) > 8:
raise NamesFileError(
'Invalid name in %s: %s' % (filename, line))
if line != '':
result.append(line.upper())
return result
def write_names_file(names, filename):
"""Write a list of names to a file.
Args:
names: List of names to write.
filename: Filename to write them to.
"""
with open(filename, "w") as f:
for name in names:
f.write("%s\n" % name)
def load_compat_textures(textures, compat_file):
"""Pre-populate a texture set from a compatibility file.
Args:
textures: TextureSet to populate.
compat_file: Path to text file containing list of textures. If
None, do not do anything.
"""
if compat_file is None:
return
for texture in read_names_file(compat_file):
textures.add_texture(texture)
class TextureConfigError(Exception):
pass
def parse_textures(stream):
"""Parse texture config from the given input stream.
Args:
stream: Input stream from which to read lines of input.
Yields:
A tuple for each parsed texture, containing:
Texture name
Width
Height
List of tuples representing each patch, where each contains:
Patch name
X offset
Y offset
"""
current_texture = None
current_patches = []
linenum = 0
for line in sys.stdin:
line = COMMENT_RE.sub('', line).strip()
linenum += 1
match = TEXTURE_NAME_RE.match(line)
if match:
if current_texture is not None:
yield current_texture
current_patches = []
current_texture = (
match.group(1), # Texture name
int(match.group(2)), # Width
int(match.group(3)), # Height
current_patches, # List of patches
)
continue
match = PATCH_NAME_RE.match(line)
if match and current_texture:
current_patches.append((
match.group(1), # Patch name
int(match.group(2)), # X offset
int(match.group(3)), # Y offset
))
continue
if line != '':
raise TextureConfigError(
'input:%i:Invalid config line: %s' %
(linenum, line))
# Last texture:
if current_texture is not None:
yield current_texture
def write_pnames_lump(pnames, filename):
"""Write a PNAMES list to a file.
Args:
pnames: List of strings containing patch names.
filename: Output filename.
"""
with open(filename, "wb") as out:
out.write(struct.pack("<l", len(pnames)))
for pname in pnames:
out.write(struct.pack("8s", pname.encode("ascii")))
def usage():
print("""
Usage: %s -output_texture1=texture1.lmp -output_pnames=pnames.lmp < config.txt
Full list of arguments:
-output_texture1: Path to the TEXTURE1 lump to generate (required).
-output_texture2: Path to the TEXTURE2 lump to generate.
-output_pnames: Path to the PNAMES lump to generate (required).
-output_pnames_txt: Path to a text file to save a list of PNAMES.
-compat_texture1: File containing compatibility list of TEXTURE1 textures
-compat_texture2: File containing compatibility list of TEXTURE2 textures
-compat_pnames: File containing compatibility list of PNAMES
""")
sys.exit(1)
def parse_command_line(args):
"""Parse command line arguments.
Args:
args: List of command line arguments to parse.
Returns:
Dictionary mapping from arg name to value.
"""
# Parse command line:
valid_args = ("compat_texture1", "compat_texture2", "compat_pnames",
"output_texture1", "output_texture2", "output_pnames",
"output_pnames_txt")
result = {arg: None for arg in valid_args}
for arg in args:
if not arg.startswith("-") or "=" not in arg:
usage()
name, value = arg[1:].split("=", 2)
if name not in valid_args:
usage()
result[name] = value
# Required args:
if not result["output_texture1"] or not result["output_pnames"]:
usage()
return result
args = parse_command_line(sys.argv[1:])
# If we have a compatibility PNAMES list, populate it. Otherwise PNAMES
# just starts from an empty list.
if args["compat_pnames"]:
pnames = read_names_file(args["compat_pnames"])
else:
pnames = []
# Generate basic data structures. The two texture lumps share a list
# of PNAMES.
texture1 = TextureSet(pnames)
texture2 = TextureSet(pnames)
# If we have compatibility lists for TEXTURE1/TEXTURE2, we need to
# populate the texture sets with them before we start parsing any real
# configuration.
load_compat_textures(texture1, args["compat_texture1"])
load_compat_textures(texture2, args["compat_texture2"])
# Parse the config file and store the texture data.
for texture, width, height, patches in parse_textures(sys.stdin):
# If this texture was predefined for TEXTURE2, put it in that lump;
# otherwise, one way or another it's going in TEXTURE1:
if texture in texture2:
textures = texture2
else:
textures = texture1
textures.add_texture(texture, width, height)
for patch, x, y in patches:
textures.add_texture_patch(texture, patch, x, y)
# Write lumps to output files:
texture1.write_texture_lump(args["output_texture1"])
if args["output_texture2"]:
texture2.write_texture_lump(args["output_texture2"])
write_pnames_lump(pnames, args["output_pnames"])
if args["output_pnames_txt"]:
write_names_file(sorted(pnames), args["output_pnames_txt"])