Center resting barrels and anchor every firing pose to the HUD edge. Add native framebuffer regression checks for all nine poses and update game assets, binaries and launcher integrity hash.
255 lines
12 KiB
Python
255 lines
12 KiB
Python
"""Import pinned Freedoom assets into native ANM1 packs (offline build tool)."""
|
|
import argparse
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import struct
|
|
import urllib.request
|
|
import wave
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SHA256 = "3f9b264f3e3ce503b4fb7f6bdcb1f419d93c7b546f4df3e874dd878db9688f59"
|
|
URL = "https://github.com/freedoom/freedoom/releases/download/v0.13.0/freedoom-0.13.0.zip"
|
|
|
|
|
|
class Wad:
|
|
def __init__(self, data):
|
|
magic, count, offset = struct.unpack_from('<4sII', data)
|
|
assert magic == b'IWAD'
|
|
self.lumps = {}
|
|
for i in range(count):
|
|
pos, size, name = struct.unpack_from('<II8s', data, offset + i * 16)
|
|
assert pos + size <= len(data)
|
|
self.lumps[name.rstrip(b'\0').decode()] = data[pos:pos + size]
|
|
self.palette = self.lumps['PLAYPAL'][:768]
|
|
|
|
def patch(self, name):
|
|
data = self.lumps[name]
|
|
w, h, left, top = struct.unpack_from('<HHhh', data)
|
|
image = Image.new('RGBA', (w, h))
|
|
pixels = image.load()
|
|
for x in range(w):
|
|
pos = struct.unpack_from('<I', data, 8 + x * 4)[0]
|
|
previous = -1
|
|
while data[pos] != 255:
|
|
y, n = data[pos], data[pos + 1]
|
|
if y <= previous:
|
|
y += previous
|
|
previous = y
|
|
for dy, index in enumerate(data[pos + 3:pos + 3 + n]):
|
|
if y + dy < h:
|
|
pixels[x, y + dy] = (*self.palette[index * 3:index * 3 + 3], 255)
|
|
pos += n + 4
|
|
return image, left, top
|
|
|
|
def flat(self, name):
|
|
image = Image.frombytes('P', (64, 64), self.lumps[name])
|
|
image.putpalette(self.palette)
|
|
return image.convert('RGBA')
|
|
|
|
def texture(self, name):
|
|
pnames = self.lumps['PNAMES']
|
|
patches = [pnames[4+i*8:12+i*8].rstrip(b'\0').decode()
|
|
for i in range(struct.unpack_from('<I', pnames)[0])]
|
|
for table in ('TEXTURE1', 'TEXTURE2'):
|
|
data = self.lumps.get(table)
|
|
if not data:
|
|
continue
|
|
for i in range(struct.unpack_from('<I', data)[0]):
|
|
pos = struct.unpack_from('<I', data, 4+i*4)[0]
|
|
texname = data[pos:pos+8].rstrip(b'\0').decode()
|
|
if texname != name:
|
|
continue
|
|
w, h = struct.unpack_from('<HH', data, pos+12)
|
|
image = Image.new('RGBA', (w, h), (0, 0, 0, 255))
|
|
count = struct.unpack_from('<H', data, pos+20)[0]
|
|
for j in range(count):
|
|
x, y, patch = struct.unpack_from('<hhH', data, pos+22+j*10)
|
|
sprite, _, _ = self.patch(patches[patch])
|
|
image.paste(sprite, (x, y), sprite)
|
|
return image
|
|
raise KeyError(name)
|
|
|
|
|
|
def pack(path, images):
|
|
w, h = images[0].size
|
|
assert all(im.size == (w, h) for im in images)
|
|
payload = b''.join(im.tobytes('raw', 'BGRA') for im in images)
|
|
path.write_bytes(struct.pack('<5I', 0x314d4e41, w, h, len(images), len(payload)) + payload)
|
|
|
|
|
|
def aligned(wad, names, size=(128, 128)):
|
|
frames = [wad.patch(n) for n in names]
|
|
# Shared Doom origin, shared scale: no per-frame trim/resize jitter.
|
|
x0 = min(-left for _, left, _ in frames)
|
|
x1 = max(im.width-left for im, left, _ in frames)
|
|
y0 = min(-top for _, _, top in frames)
|
|
y1 = max(im.height-top for im, _, top in frames)
|
|
scale = min(size[0] / (x1-x0), size[1] / (y1-y0))
|
|
result = []
|
|
for im, left, top in frames:
|
|
canvas = Image.new('RGBA', size)
|
|
im = im.resize((max(1, round(im.width*scale)), max(1, round(im.height*scale))), Image.Resampling.NEAREST)
|
|
x = round((-left-x0)*scale + (size[0]-(x1-x0)*scale)/2)
|
|
y = round((-top-y0)*scale + size[1]-(y1-y0)*scale)
|
|
canvas.paste(im, (x, y))
|
|
result.append(canvas)
|
|
return result
|
|
|
|
|
|
def weapon_frames(wad, names):
|
|
"""Register by resting barrel, not animation bounds; keep wrists at HUD edge."""
|
|
frames = aligned(wad, names, (192,160))
|
|
alpha = frames[0].getchannel('A')
|
|
top = alpha.getbbox()[1]
|
|
tip = alpha.crop((0,top,192,top+4)).getbbox()
|
|
shift_x = round(160-(tip[0]+tip[2])/2)
|
|
result = []
|
|
for image in frames:
|
|
bottom = image.getchannel('A').getbbox()[3]
|
|
canvas = Image.new('RGBA',(320,160))
|
|
# Extra horizontal canvas retains the shotgun's leftward pump motion.
|
|
# Same scale and horizontal translation for every frame of each weapon.
|
|
canvas.paste(image,(shift_x,160-bottom))
|
|
result.append(canvas)
|
|
return result
|
|
|
|
|
|
def interface_assets(wad, out, manifest):
|
|
"""Doom-layout status bar, native-size glyphs and common-origin face frames."""
|
|
pack(out/'hud.bin', [wad.patch('STBAR')[0]])
|
|
names = [f'STTNUM{i}' for i in range(10)] + ['STTPRCNT']
|
|
digits = []
|
|
for name in names:
|
|
image = wad.patch(name)[0]
|
|
canvas = Image.new('RGBA', (16,16))
|
|
canvas.paste(image, (16-image.width,0))
|
|
digits.append(canvas)
|
|
pack(out/'digits.bin', digits)
|
|
names_face = [name for health in range(5)
|
|
for name in [f'STFST{health}0', f'STFST{health}1',
|
|
f'STFST{health}2', f'STFOUCH{health}']] + ['STFDEAD0']
|
|
faces = []
|
|
for name in names_face:
|
|
image = wad.patch(name)[0]
|
|
canvas = Image.new('RGBA', (24,32))
|
|
canvas.paste(image, ((24-image.width)//2,32-image.height))
|
|
faces.append(canvas)
|
|
pack(out/'faces.bin', faces)
|
|
impacts = ['PUFFA0','PUFFB0','PUFFC0','PUFFD0']
|
|
pack(out/'impacts.bin', aligned(wad, impacts, (32,32)))
|
|
manifest['packs'].update(hud=['STBAR'], digits=names, faces=names_face, impacts=impacts)
|
|
|
|
# Authored landscape, entirely composed from Freedoom textures and sprites.
|
|
# Opaque power-of-two 512x256 BGRA surface; wall renderer wraps with bit masks.
|
|
image = Image.new('RGBA',(512,192),(20,9,8,255))
|
|
sky = wad.texture('SKY3').resize((488,166),Image.Resampling.NEAREST)
|
|
image.paste(sky,(12,12))
|
|
draw = ImageDraw.Draw(image)
|
|
draw.polygon([(12,136),(65,98),(96,126),(139,86),(174,125),(222,104),
|
|
(273,142),(321,90),(376,123),(423,94),(500,130),(500,180),(12,180)],
|
|
fill=(34,20,22,255))
|
|
lava = wad.flat('LAVA1').resize((488,48),Image.Resampling.NEAREST)
|
|
image.paste(lava,(12,132))
|
|
for x,h in [(42,70),(92,52),(362,62),(428,84)]:
|
|
stone = wad.texture('GSTONE1').resize((28,h),Image.Resampling.NEAREST)
|
|
image.paste(stone,(x,164-h))
|
|
draw.rectangle((x-3,160-h,x+30,165-h),fill=(107,72,45,255))
|
|
for name,x,y,w,h in [('BOSSA1',224,48,70,116),('TROOA1',145,104,34,58),
|
|
('SARGA1',330,112,42,52)]:
|
|
actor = wad.patch(name)[0]
|
|
actor.thumbnail((w,h),Image.Resampling.NEAREST)
|
|
image.alpha_composite(actor,(x,y))
|
|
# Layered gilt moulding and dark inner bevel: a painting, not an open portal.
|
|
for inset,color in [(0,(42,25,12)),(2,(199,147,64)),(4,(104,61,22)),
|
|
(6,(236,191,99)),(8,(116,74,28)),(10,(26,13,8))]:
|
|
draw.rectangle((inset,inset,511-inset,191-inset),outline=(*color,255),width=2)
|
|
for x in range(18,495,16):
|
|
draw.line((x,4,x+3,7),fill=(255,216,126,255),width=1)
|
|
draw.line((x,184,x+3,187),fill=(255,216,126,255),width=1)
|
|
pack(out/'painting.bin',[image.resize((512,256),Image.Resampling.NEAREST)])
|
|
image.save(out/'painting-preview.png')
|
|
manifest['packs']['painting'] = ['SKY3','LAVA1','GSTONE1','BOSSA1','TROOA1','SARGA1']
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('archive', type=Path)
|
|
args = parser.parse_args()
|
|
data = args.archive.read_bytes()
|
|
assert hashlib.sha256(data).hexdigest() == SHA256, 'Freedoom archive hash mismatch'
|
|
archive = zipfile.ZipFile(io.BytesIO(data))
|
|
wad = Wad(archive.read(next(n for n in archive.namelist() if n.endswith('/freedoom2.wad'))))
|
|
out = ROOT / 'assets/hell'
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
manifest = {'version': '0.13.0', 'url': URL, 'archive_sha256': SHA256, 'packs': {}}
|
|
monsters = {
|
|
'soldier': ['POSSA1','POSSB1','POSSC1','POSSD1','POSSE1','POSSF1','POSSG1','POSSH0','POSSI0','POSSJ0','POSSK0','POSSL0'],
|
|
'demon': ['SARGA1','SARGB1','SARGC1','SARGD1','SARGE1','SARGF1','SARGH1','SARGI0','SARGJ0','SARGK0','SARGL0','SARGN0'],
|
|
'imp': ['TROOA1','TROOB1','TROOC1','TROOD1','TROOE1','TROOF1','TROOH1','TROOI0','TROOJ0','TROOK0','TROOL0','TROOM0'],
|
|
'boss': ['BOSSA1','BOSSB1','BOSSC1','BOSSD1','BOSSE1','BOSSF1','BOSSH1','BOSSI0','BOSSJ0','BOSSK0','BOSSL0','BOSSO0'],
|
|
'pistol': ['PISGA0','PISGB0','PISGC0','PISGD0','PISGE0'],
|
|
'shotgun': ['SHTGA0','SHTGB0','SHTGC0','SHTGD0'],
|
|
'fire': ['BAL1A0','BAL1B0','BAL1C0','BAL1D0','BAL1E0'],
|
|
'portal': ['TFOGA0','TFOGB0','TFOGC0','TFOGD0','TFOGE0','TFOGF0','TFOGG0','TFOGH0'],
|
|
}
|
|
for name, names in monsters.items():
|
|
# Some Doom rotations are packed in mirrored pairs (A2A8 etc.).
|
|
for i, lump in enumerate(names):
|
|
if lump not in wad.lumps:
|
|
choices = [n for n in wad.lumps if n.startswith(lump[:5]) and len(n) in (6,8)]
|
|
if not choices:
|
|
raise KeyError(lump)
|
|
names[i] = sorted(choices)[0]
|
|
images = weapon_frames(wad,names) if name in ('pistol','shotgun') else aligned(wad,names)
|
|
if name == 'portal':
|
|
# Recolour original Freedoom teleport animation to infernal orange/red.
|
|
for image in images:
|
|
image.putdata([(max(r,g), min(180,g//2), b//4, a)
|
|
for r,g,b,a in image.getdata()])
|
|
pack(out / (name+'.bin'), images)
|
|
manifest['packs'][name] = names
|
|
items = ['MEDIA0','ARM1A0','CLIPA0','SHELA0','SHOTA0','BKEYA0','BON2A0']
|
|
pack(out/'items.bin', [aligned(wad, [n])[0] for n in items])
|
|
manifest['packs']['items'] = items
|
|
textures = ['MARBLE1','GSTONE1','SKIN2','BIGDOOR2','SW1GARG','SP_HOT1']
|
|
flats = ['FLOOR7_2','F_SKY1','LAVA1','LAVA2','LAVA3','LAVA4']
|
|
images = [wad.texture(n).resize((128,128),Image.Resampling.NEAREST) for n in textures]
|
|
# F_SKY1 is a marker, use a real stone flat for ceiling.
|
|
flats[1] = 'CEIL5_2'
|
|
images += [wad.flat(n).resize((128,128),Image.Resampling.NEAREST) for n in flats]
|
|
pack(out/'materials.bin', images)
|
|
manifest['packs']['materials'] = textures+flats
|
|
interface_assets(wad,out,manifest)
|
|
for name in ['DSPISTOL','DSSHOTGN','DSSGCOCK','DSPLPAIN','DSPOPAIN','DSFIRSHT','DSPODTH1','DSITEMUP','DSDOROPN','DSTELEPT']:
|
|
raw = wad.lumps[name]
|
|
fmt, rate, count = struct.unpack_from('<HHI', raw)
|
|
assert fmt == 3 and count <= len(raw)-8
|
|
with wave.open(str(out/(name.lower()+'.wav')), 'wb') as wav:
|
|
wav.setparams((1,1,rate,0,'NONE','not compressed'))
|
|
wav.writeframes(raw[24:8+count-16])
|
|
for basename in ('COPYING','CREDITS'):
|
|
names = [n for n in archive.namelist() if Path(n).name.startswith(basename)]
|
|
if names:
|
|
(out/(basename+'.txt')).write_bytes(archive.read(names[0]))
|
|
else:
|
|
url = f'https://raw.githubusercontent.com/freedoom/freedoom/v0.13.0/{basename}.adoc'
|
|
(out/(basename+'.txt')).write_bytes(urllib.request.urlopen(url).read())
|
|
(out/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n',encoding='utf-8')
|
|
sheet = Image.new('RGBA',(6*128,3*128),(30,25,25,255))
|
|
for i, name in enumerate(monsters):
|
|
raw = (out/(name+'.bin')).read_bytes()
|
|
_,w,h,_,_ = struct.unpack_from('<5I',raw)
|
|
im = Image.frombytes('RGBA',(w,h),raw[20:20+w*h*4],'raw','BGRA')
|
|
im.thumbnail((128,128),Image.Resampling.NEAREST)
|
|
sheet.paste(im,((i%6)*128,(i//6)*128),im)
|
|
sheet.save(out/'preview.png')
|
|
print(f'Imported {len(manifest["packs"])} packs to {out}')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|