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.
94 lines
4.6 KiB
Python
94 lines
4.6 KiB
Python
"""Check first-person weapon registration, including every firing pose."""
|
|
from pathlib import Path
|
|
import struct
|
|
from PIL import Image, ImageChops, ImageDraw
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
OUT = ROOT / 'build/scratch'
|
|
|
|
|
|
def check_assets():
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
sheet = Image.new('RGB', (5*288, 2*260), (40,44,50))
|
|
draw = ImageDraw.Draw(sheet)
|
|
errors = []
|
|
for row, name in enumerate(('pistol','shotgun')):
|
|
raw = (ROOT/f'assets/hell/{name}.bin').read_bytes()
|
|
magic,w,h,count,size = struct.unpack_from('<5I',raw)
|
|
assert magic == 0x314d4e41 and (w,h) == (320,160)
|
|
assert size == w*h*count*4 == len(raw)-20
|
|
for frame in range(count):
|
|
start = 20+frame*w*h*4
|
|
image = Image.frombytes('RGBA',(w,h),raw[start:start+w*h*4],'raw','BGRA')
|
|
alpha = image.getchannel('A')
|
|
bounds = alpha.getbbox()
|
|
if bounds[0] == 0 or bounds[2] == w:
|
|
errors.append(f'{name} frame {frame}: weapon touches horizontal canvas edge')
|
|
if bounds[3] != h:
|
|
errors.append(f'{name} frame {frame}: hands end {h-bounds[3]} px above panel')
|
|
if frame == 0:
|
|
# Upper tip of resting barrel must register at screen centre.
|
|
tip = alpha.crop((0,bounds[1],w,bounds[1]+4)).getbbox()
|
|
centre = (tip[0]+tip[2])/2
|
|
if abs(centre-w/2) > 1:
|
|
errors.append(f'{name}: resting barrel x={centre:g}, expected {w/2:g}')
|
|
# Crop equal empty margins for the compact contact sheet.
|
|
preview = image.resize((480,240),Image.Resampling.NEAREST).crop((96,0,384,240))
|
|
sheet.paste(preview,(frame*288,row*260),preview)
|
|
draw.line((frame*288+144,row*260,frame*288+144,row*260+239), fill=(170,60,60))
|
|
draw.line((frame*288,row*260+240,frame*288+287,row*260+240), fill=(200,180,110))
|
|
draw.text((frame*288+4,row*260+245),f'{name} {frame}',fill='white')
|
|
sheet.save(OUT/'hell-weapon-alignment.png')
|
|
assert not errors, '\n'.join(errors)
|
|
|
|
|
|
def check_native():
|
|
"""Compare full ASM scene captures with fixed-point, nearest-neighbour overlays."""
|
|
sheet = Image.new('RGB', (5*320, 2*240))
|
|
draw = ImageDraw.Draw(sheet)
|
|
index = 0
|
|
for row, name in enumerate(('pistol','shotgun')):
|
|
raw = (ROOT/f'assets/hell/{name}.bin').read_bytes()
|
|
_,w,h,count,_ = struct.unpack_from('<5I',raw)
|
|
base = Image.frombytes('RGBA',(640,480),
|
|
(OUT/f'hell-weapon-{name}-base.bgra').read_bytes(),
|
|
'raw','BGRA').convert('RGB')
|
|
for frame in range(count):
|
|
start = 20+frame*w*h*4
|
|
source = Image.frombytes('RGBA',(w,h),raw[start:start+w*h*4],'raw','BGRA')
|
|
expected = base.copy()
|
|
pixels = expected.load()
|
|
sprite = source.load()
|
|
# Match DrawAnimSprite3D's truncated 16.16 steps, not Pillow's sampling.
|
|
xstep,ystep = (w<<16)//480,(h<<16)//240
|
|
for y in range(240):
|
|
for x in range(480):
|
|
color = sprite[(x*xstep)>>16,(y*ystep)>>16]
|
|
if color[3]:
|
|
assert color[3] == 255
|
|
pixels[80+x,176+y] = color[:3]
|
|
# Crosshair is drawn over the weapon, as in the native renderer.
|
|
for bounds in ((318,234,322,248),(312,240,328,242)):
|
|
expected.paste(base.crop(bounds),bounds[:2])
|
|
actual = Image.frombytes('RGBA',(640,480),
|
|
(OUT/f'hell-weapon-{index}.bgra').read_bytes(),
|
|
'raw','BGRA').convert('RGB')
|
|
assert ImageChops.difference(actual,expected).getbbox() is None, \
|
|
f'{name} frame {frame}: ASM placement/pose differs from asset contract'
|
|
assert ImageChops.difference(actual,base).crop((80,415,560,416)).getbbox(), \
|
|
f'{name} frame {frame}: native hands do not meet HUD at y=416'
|
|
sheet.paste(actual.resize((320,240),Image.Resampling.NEAREST),(frame*320,row*240))
|
|
draw.text((frame*320+4,row*240+4),f'{name} {frame}',fill='white')
|
|
index += 1
|
|
sheet.save(OUT/'hell-weapon-native.png')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
import argparse
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('--native',action='store_true',help='also validate captures from check_hell_fps.py')
|
|
options = parser.parse_args()
|
|
check_assets()
|
|
if options.native:
|
|
check_native()
|
|
print('PASS: centred resting barrels; all weapon poses meet the status bar')
|