Add independent Rust engine, deterministic mod API and asset pipeline

This commit is contained in:
raptorreplica committed 2026-10-10 12:17:43 +11:00
1 parent f2cce6f51b
commit dbb1d4596b
53 files changed
+18314 -56

No files matched your search

+73
View File
@@ -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())
+73
View File
@@ -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')
+63
View File
@@ -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()