mirror of
https://github.com/freedoom/freedoom.git
synced 2025-09-01 22:25:46 -04:00
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.
380 lines
12 KiB
Python
Executable file
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"]
|
|
)
|