freedoom/lumps/textures/build-textures
Mike Swanson 6eef9be73a use python3 only for building
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.
2019-09-06 14:43:50 -07:00

380 lines
12 KiB
Python
Executable file

#!/usr/bin/env python3
# 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 os.path
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, sprites_dir):
"""Write a list of names to a file.
Args:
names: List of names to write.
filename: Filename to write them to.
sprites_dir: Path to directory containing sprites. If a file is found
in this directory matching a patch name, that patch will not be
included in the outputted list.
"""
with open(filename, "w") as f:
for name in names:
filename = "%s.png" % name.lower()
sprite_path = os.path.join(sprites_dir, filename)
if not os.path.exists(sprite_path):
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.
-sprites_dir: Path to the sprites directory, used to identify patches which
are actually embedded sprites. These sprites will not be included in the
outputted pnames file.
-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",
"sprites_dir",
)
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"], args["sprites_dir"]
)