Add independent Rust engine, deterministic mod API and asset pipeline
This commit is contained in:
1 parent
f2cce6f51b
commit
dbb1d4596b
53 files changed
+18314
-56
No files matched your search
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare complete JSONL gameplay snapshots, reporting the first state difference.
|
||||
|
||||
Both files must be recorded after the same complete simulation update. Property
|
||||
order is ignored; entity and projectile list order is part of the contract.
|
||||
Original data and generated traces belong in the ignored .reference directory.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def first_difference(left, right, path="state"):
|
||||
if type(left) is not type(right):
|
||||
return path, left, right
|
||||
if isinstance(left, dict):
|
||||
if left.keys() != right.keys():
|
||||
return path + ".keys", sorted(left), sorted(right)
|
||||
for key in sorted(left):
|
||||
difference = first_difference(left[key], right[key], f"{path}.{key}")
|
||||
if difference:
|
||||
return difference
|
||||
elif isinstance(left, list):
|
||||
if len(left) != len(right):
|
||||
return path + ".length", len(left), len(right)
|
||||
for index, (a, b) in enumerate(zip(left, right)):
|
||||
difference = first_difference(a, b, f"{path}[{index}]")
|
||||
if difference:
|
||||
return difference
|
||||
elif left != right:
|
||||
return path, left, right
|
||||
return None
|
||||
|
||||
|
||||
def read_trace(path):
|
||||
records = []
|
||||
with Path(path).open(encoding="utf-8") as file:
|
||||
for line_number, line in enumerate(file, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
record = json.loads(line)
|
||||
if not isinstance(record, dict) or record.get("tick") != len(records) + 1:
|
||||
raise ValueError(f"{path}:{line_number}: expected consecutive tick {len(records) + 1}")
|
||||
records.append(record)
|
||||
if not records:
|
||||
raise ValueError(f"{path}: no snapshots")
|
||||
return records
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("reference")
|
||||
parser.add_argument("candidate")
|
||||
args = parser.parse_args()
|
||||
reference = read_trace(args.reference)
|
||||
candidate = read_trace(args.candidate)
|
||||
for tick, (left, right) in enumerate(zip(reference, candidate), 1):
|
||||
difference = first_difference(left, right)
|
||||
if difference:
|
||||
path, a, b = difference
|
||||
print(f"Divergence at tick {tick}: {path}")
|
||||
print(f" reference: {a!r}")
|
||||
print(f" candidate: {b!r}")
|
||||
return 1
|
||||
if len(reference) != len(candidate):
|
||||
print(f"Trace length differs: {len(reference)} reference steps, {len(candidate)} candidate steps")
|
||||
return 1
|
||||
print(f"Identical gameplay snapshots across {len(reference)} simulation steps.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate reviewable Rust constants from the preserved GPL engine source.
|
||||
This development tool is not used by the Rust game at build or runtime.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
ids={k:int(v,16) for k,v in re.findall(r'#define\s+(FILE\w+)\s+(0x[0-9a-fA-F]+)',(ROOT/'raptor/src/fileids.h').read_text())}
|
||||
enum=(ROOT/'raptor/src/objects.h').read_text().split('S_FORWARD_GUNS',1)[1].split('}OBJ_TYPE')[0]
|
||||
names=['S_FORWARD_GUNS']+re.findall(r'^\s*(S_\w+)\s*[,\n]',enum,re.M)
|
||||
names=[n for n in names if n!='S_LAST_OBJECT']
|
||||
values={**ids,**{n:i for i,n in enumerate(names)},'S_ALL':0,'S_AIR':1,'S_GROUND':2,'S_GRALL':3,'S_GTILE':4,'S_SUCK':5,'S_SHOOT':0,'S_LINE':1,'S_BEAM':2,'MAX_SHIELD':100}
|
||||
def number(s,default=0):
|
||||
s=s.strip()
|
||||
if s in values:return values[s]
|
||||
if re.fullmatch(r'-?\d+',s):return int(s)
|
||||
if re.fullmatch(r'0x[0-9a-fA-F]+',s):return int(s,16)
|
||||
if '/' in s:
|
||||
a,b=s.split('/')
|
||||
return number(a)//number(b)
|
||||
raise ValueError(f'Unsupported numeric expression: {s}')
|
||||
def blocks(file,var,start_pattern):
|
||||
text=(ROOT/file).read_text(); starts=list(re.finditer(start_pattern,text));result={}
|
||||
for i,m in enumerate(starts):
|
||||
block=text[m.end():starts[i+1].start() if i+1<len(starts) else len(text)]
|
||||
f={}
|
||||
wanted={'item','numframes','cost','start_cnt','max_cnt','specialw','onlyflag'} if var=='lib' else {'lumpnum','numframes','hits','speed','maxspeed','shoot_rate','ht','startframe','delayflag','use_plot','move_flag','fplrx','fplry','meffect','beam'}
|
||||
for k,v in re.findall(rf'{var}->(\w+)\s*=\s*([^;]+);',block):
|
||||
if k in wanted:
|
||||
f[k]=f.get(v.strip().removeprefix(var+'->')) if v.strip().startswith(var+'->') else number(v)
|
||||
result[m[1]]=f
|
||||
return result
|
||||
objects=blocks('raptor/src/objects.cpp','lib',r'lib\s*=\s*&obj_lib\[(S_\w+)\];')
|
||||
weapons=blocks('raptor/src/shots.cpp','slib',r'slib\s*=\s*&shot_lib\[(S_\w+)\];')
|
||||
id_of=lambda n:'base:'+n[2:].lower().replace('missle','missile')
|
||||
name_of=lambda n:n[2:].replace('_',' ').title().replace('Missle','Missile')
|
||||
out=['// Generated by tools/generate_legacy_tables.py; source is GPL-2.0-or-later.','pub struct LegacyObject{pub index:u32,pub id:&\'static str,pub name:&\'static str,pub asset:u32,pub frames:u32,pub cost:u32,pub max_count:u32,pub initial_count:u32,pub special:bool}','pub struct LegacyWeapon{pub index:u32,pub id:&\'static str,pub name:&\'static str,pub asset:u32,pub frames:u32,pub damage:i32,pub speed:i32,pub cooldown:u32,pub target:u32,pub maxspeed:i32,pub startframe:u32,pub delay:bool,pub plot:bool,pub moving:bool,pub follow_x:bool,pub follow_y:bool,pub effect:bool,pub beam:u32}','pub fn objects()->Vec<LegacyObject>{vec![']
|
||||
for n in names:
|
||||
f=objects.get(n,{})
|
||||
out.append(f'LegacyObject{{index:{values[n]},id:"{id_of(n)}",name:"{name_of(n)}",asset:{max(0,f.get("item",0))},frames:{f.get("numframes",1)},cost:{max(0,f.get("cost",0))*(f.get("start_cnt",1) if f.get("onlyflag") else 1)},max_count:{max(1,f.get("max_cnt",1))},initial_count:{max(1,f.get("start_cnt",1))},special:{str(bool(f.get("specialw",0))).lower()}}},')
|
||||
out+= [']}','pub fn weapons()->Vec<LegacyWeapon>{vec![']
|
||||
for n,f in weapons.items():
|
||||
out.append(f'LegacyWeapon{{index:{values[n]},id:"{id_of(n)}",name:"{name_of(n)}",asset:{max(0,f.get("lumpnum",0))},frames:{f.get("numframes",1)},damage:{f.get("hits",1)},speed:{f.get("speed",0)},cooldown:{f.get("shoot_rate",1)},target:{f.get("ht",0)},maxspeed:{f.get("maxspeed",0)},startframe:{f.get("startframe",0)},delay:{str(bool(f.get("delayflag",0))).lower()},plot:{str(bool(f.get("use_plot",0))).lower()},moving:{str(bool(f.get("move_flag",0))).lower()},follow_x:{str(bool(f.get("fplrx",0))).lower()},follow_y:{str(bool(f.get("fplry",0))).lower()},effect:{str(bool(f.get("meffect",0))).lower()},beam:{f.get("beam",0)}}},')
|
||||
out +=[']}']
|
||||
rap=(ROOT/'raptor/src/rap.cpp').read_text()
|
||||
for name in ['o_gun1','o_gun2','o_gun3']:
|
||||
block=re.search(rf'{name}\[8\]\s*=\s*\{{(.*?)\}};',rap,re.S)[1]
|
||||
nums=[int(x.strip(),0) for x in block.split(',') if x.strip()]
|
||||
out.append(f'pub const {name.upper()}: [i32; 8] = ['+', '.join(map(str,nums))+'];')
|
||||
fx=(ROOT/'raptor/src/fx.cpp').read_text()
|
||||
starts=list(re.finditer(r'lib\s*=\s*&fx_items\[(FX_\w+)\];',fx))
|
||||
out.append("pub struct LegacySound {pub name: &'static str,pub asset:u32,pub pitch:i32,pub random:bool,pub volume:i32,pub priority:i32}")
|
||||
out.append('pub fn sounds()->Vec<LegacySound>{vec![')
|
||||
for i,m in enumerate(starts):
|
||||
block=fx[m.end():starts[i+1].start() if i+1<len(starts) else len(fx)]
|
||||
item=re.search(r'lib->item\s*=\s*GLB_GetItemID\("(\w+)"\)',block)
|
||||
if not item: continue
|
||||
matches=[v for k,v in ids.items() if k.endswith('_'+item[1])]
|
||||
if len(matches)!=1: raise ValueError(f'Ambiguous sound {item[1]}: {matches}')
|
||||
pitch=int(re.search(r'lib->pitch\s*=\s*(-?\d+)',block)[1])
|
||||
random=int(re.search(r'lib->rpflag\s*=\s*(\d+)',block)[1])
|
||||
volume=int(re.search(r'lib->vol\s*=\s*(-?\d+)',block)[1])
|
||||
priority=int(re.search(r'lib->pri\s*=\s*(-?\d+)',block)[1])
|
||||
out.append(f'LegacySound{{name:"{m[1]}",asset:{matches[0]},pitch:{pitch},random:{str(bool(random)).lower()},volume:{volume},priority:{priority}}},')
|
||||
out.append(']}')
|
||||
audio=(ROOT/'raptor/src/i_oplmusic.cpp').read_text()
|
||||
for name,rust in [('frequency_curve','FREQUENCY_CURVE'),('volume_mapping_table','VOLUME_CURVE')]:
|
||||
block=re.search(rf'{name}\[\]\s*=\s*\{{(.*?)\}};',audio,re.S)[1]
|
||||
block=re.sub(r'//[^\n]*','',block)
|
||||
nums=[int(x.strip(),0) for x in block.split(',') if x.strip()]
|
||||
out.append(f'pub const {rust}: [u16; {len(nums)}] = ['+', '.join(map(str,nums))+'];')
|
||||
(ROOT/'src/legacy_tables.rs').write_text('\n'.join(out)+'\n')
|
||||
print(f'Generated {len(names)} items and {len(weapons)} weapons')
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decode a catalog picture into transparent PNG source references; never redraw art."""
|
||||
import argparse
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
def decode(path, palette):
|
||||
data = path.read_bytes()
|
||||
kind, _, _, width, height = struct.unpack_from('<iiiii', data)
|
||||
if not (0 < width <= 4096 and 0 < height <= 4096):
|
||||
raise ValueError('invalid picture dimensions')
|
||||
pixels = bytearray(width * height * 4)
|
||||
def put(x, y, value):
|
||||
p = (y * width + x) * 4
|
||||
pixels[p:p+4] = bytes((*palette[value], 255))
|
||||
if kind == 1:
|
||||
if len(data) < 20 + width * height:
|
||||
raise ValueError('truncated picture')
|
||||
for p, value in enumerate(data[20:20 + width * height]):
|
||||
put(p % width, p // width, value)
|
||||
elif kind == 0:
|
||||
off = 20
|
||||
while True:
|
||||
x, y, dest, length = struct.unpack_from('<iiii', data, off)
|
||||
off += 16
|
||||
if dest == -1:
|
||||
break
|
||||
if not (0 <= x < width and 0 <= y < height and 0 < length <= width - x and off + length <= len(data)):
|
||||
raise ValueError('invalid picture span')
|
||||
for i, value in enumerate(data[off:off+length]):
|
||||
put(x+i, y, value)
|
||||
off += length
|
||||
else:
|
||||
raise ValueError('unsupported picture type')
|
||||
return Image.frombytes('RGBA', (width, height), bytes(pixels))
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--data', type=Path, default=Path('data'))
|
||||
p.add_argument('--asset', required=True, type=lambda s: int(s, 16))
|
||||
p.add_argument('--output', type=Path, required=True)
|
||||
p.add_argument('--preview-scale', type=int, default=1)
|
||||
args = p.parse_args()
|
||||
entries = {int(i, 16):(int(n), Path(name)) for i,n,name in (line.split() for line in (args.data/'INDEX').read_text().splitlines() if line.strip())}
|
||||
pal = (args.data/'misc/PALETTE_DAT').read_bytes()
|
||||
if len(pal) != 768:
|
||||
raise ValueError('invalid VGA palette')
|
||||
palette = [tuple((min(c,63)<<2)|(min(c,63)>>4) for c in pal[i:i+3]) for i in range(0,768,3)]
|
||||
size, relative = entries[args.asset]
|
||||
source = (args.data / relative).resolve()
|
||||
if not source.is_relative_to(args.data.resolve()) or source.stat().st_size != size:
|
||||
raise ValueError('invalid catalog entry')
|
||||
image = decode(source, palette)
|
||||
if args.preview_scale < 1 or args.preview_scale > 32:
|
||||
raise ValueError('preview scale must be 1..32')
|
||||
if args.preview_scale != 1:
|
||||
image = image.resize((image.width * args.preview_scale, image.height * args.preview_scale), Image.Resampling.NEAREST)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(args.output)
|
||||
print(f'{args.asset:05x}: {image.width}x{image.height} -> {args.output}')
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user