Package RustRaptor and refine controls, menus, saves, sound and mod selection

This commit is contained in:
raptorreplica committed 2026-10-10 13:47:41 +11:00
1 parent 0844f6dffa
commit e17bc1003e
649 files changed
+8652 -2677

No files matched your search

-264
View File
@@ -1,264 +0,0 @@
//! Opt-in original-catalog campaign inspection harness.
//!
//! This is coverage, not a gameplay parity proof or an ordinary player victory.
//! The fixture owns the original arsenal, fires continuously, aims horizontally
//! at visible enemies, cycles specials every 180 steps and uses five mega bombs.
//! Before each step, shields are replenished to the original 100+100 maxima.
//! No enemies, definitions, damage, spawn timings, RNG or outcomes are modified.
//!
//! Used by tests/campaigns.rs. To compile as a local reporting CLI, pass the built
//! raptor_replica and anyhow rlibs to rustc --extern, with
//! -L dependency=target/release/deps. See docs/campaign-validation.md.
use raptor_replica::{
assets::Catalog,
content::{ContentId, Registry},
modding::ScriptRuntime,
persistence::Pilot,
simulation::{Input, Outcome, World},
};
use std::{collections::BTreeSet, path::Path};
#[derive(Debug)]
pub struct WaveReport {
pub mission: ContentId,
pub difficulty: u32,
pub ticks: u64,
pub outcome: Outcome,
pub visited_spawns: usize,
pub total_spawns: usize,
pub enabled_spawns: usize,
pub expected_bosses: usize,
pub bosses_seen: usize,
pub bosses_killed: usize,
pub bosses_left_alive: usize,
pub shield_replenishments: usize,
pub credits: u32,
pub digest: String,
pub remaining: Vec<String>,
}
impl WaveReport {
pub fn completed(&self) -> bool {
self.outcome == Outcome::Complete
}
pub fn csv(&self) -> String {
format!(
"{},{},{},{:?},{}/{},{},{},{},{},{},{},{},{},{}",
self.mission,
self.difficulty,
self.ticks,
self.outcome,
self.visited_spawns,
self.total_spawns,
self.enabled_spawns,
self.expected_bosses,
self.bosses_seen,
self.bosses_killed,
self.bosses_left_alive,
self.shield_replenishments,
self.credits,
self.remaining.len(),
self.digest
)
}
}
pub fn csv_header() -> &'static str {
"mission,difficulty,ticks,outcome,visited_spawns,eligible_spawns,expected_bosses,seen_bosses,killed_bosses,left_alive,replenishments,credits,remaining_enemies,state_digest"
}
pub fn catalog_registry(path: &Path) -> anyhow::Result<(Catalog, Registry)> {
let catalog = Catalog::open(path)?;
let registry = Registry::base(&catalog)?;
Ok((catalog, registry))
}
fn enabled(difficulty: u32, level: u32) -> bool {
match level {
3 => true,
4 => difficulty >= 2,
5 => difficulty >= 3,
_ => false,
}
}
pub fn arsenal_pilot(mission: ContentId, difficulty: u32, registry: &Registry) -> Pilot {
let mut pilot = Pilot {
mission,
difficulty,
credits: 10000,
inventory: Default::default(),
..Pilot::default()
};
for item in registry
.items
.values()
.filter(|i| i.legacy.is_some_and(|id| id <= 17))
{
let count = match item.legacy.unwrap() {
11 => 5,
15 | 16 => 100,
_ => 1,
};
pilot
.inventory
.insert(item.id.clone(), count.min(item.max_count));
}
pilot.selected = Some("base:forward_laser".into());
pilot
}
pub fn run_wave(
registry: &Registry,
sector: u32,
wave: u32,
difficulty: u32,
maximum: u64,
) -> anyhow::Result<WaveReport> {
let mission: ContentId = format!("base:sector_{sector}_wave_{wave}").as_str().into();
let pilot = arsenal_pilot(mission.clone(), difficulty, registry);
pilot.validate(registry, &[])?;
let definition = &registry.missions[&mission];
let eligible: Vec<_> = definition
.spawns
.iter()
.filter(|s| enabled(difficulty, s.difficulty))
.collect();
let expected_bosses = eligible
.iter()
.filter(|s| registry.enemies[&s.enemy].boss)
.count();
let mut world = World::new(&mission, &pilot, registry)?;
let mut scripts = ScriptRuntime::new();
let mut bosses = BTreeSet::new();
let mut killed = BTreeSet::new();
let mut left_alive = BTreeSet::new();
let mut replenishments = 0;
for tick in 0..maximum {
if world.outcome != Outcome::Playing {
break;
}
if world.player.shield < 100 || world.player.super_shield < 100 {
replenishments += 1;
}
// Explicit inspection assistance. Do not infer normal-player survival.
world.player.shield = 100;
world.player.super_shield = 100;
let target = world
.enemies
.iter()
.filter(|e| e.health > 0 && e.y + e.height > 0 && e.y < 160)
.min_by_key(|e| {
(
!registry.enemies[&e.definition].boss,
(e.x + e.width / 2 - (world.player.x + 16)).abs(),
)
});
let x = target
.map_or_else(
|| {
let phase = (tick % 240) as i32;
20 + if phase < 120 {
phase * 2
} else {
(240 - phase) * 2
}
},
|e| e.x + e.width / 2 - 16,
)
.clamp(5, 282);
let prior: Vec<_> = world
.enemies
.iter()
.filter(|e| registry.enemies[&e.definition].boss)
.map(|e| (e.entity, e.health))
.collect();
world.step(
Input {
absolute: Some([x, 160]),
fire: true,
cycle: tick % 180 == 0 && tick > 0,
bomb: tick % 480 == 240,
..Input::default()
},
registry,
&mut scripts,
)?;
for e in &world.enemies {
if registry.enemies[&e.definition].boss {
bosses.insert(e.entity);
}
}
for (entity, health) in prior {
if !world.enemies.iter().any(|e| e.entity == entity) {
if health <= 0 {
killed.insert(entity);
} else {
left_alive.insert(entity);
}
}
}
}
Ok(WaveReport {
mission,
difficulty,
ticks: world.tick,
outcome: world.outcome.clone(),
visited_spawns: world.spawn_cursor,
total_spawns: definition.spawns.len(),
enabled_spawns: eligible.len(),
expected_bosses,
bosses_seen: bosses.len(),
bosses_killed: killed.len(),
bosses_left_alive: left_alive.len(),
shield_replenishments: replenishments,
credits: world.player.credits,
digest: world.digest(),
remaining: world
.enemies
.iter()
.map(|e| {
format!(
"{}@{},{} health={} move={},{}-> {},{}",
e.definition,
e.x,
e.y,
e.health,
e.movement.x,
e.movement.y,
e.movement.tx,
e.movement.ty
)
})
.collect(),
})
}
#[allow(dead_code)]
fn main() -> anyhow::Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
let root = args
.first()
.map(|s| Path::new(s))
.unwrap_or_else(|| Path::new("data"));
let filter = args.get(1).map(|s| s.parse::<u32>()).transpose()?;
let (_catalog, registry) = catalog_registry(root)?;
println!("{}", csv_header());
let mut incomplete = 0;
for difficulty in 0..=3 {
if filter.is_some_and(|d| d != difficulty) {
continue;
}
for sector in 1..=3 {
for wave in 1..=9 {
let report = run_wave(&registry, sector, wave, difficulty, 12000)?;
println!("{}", report.csv());
if !report.completed() {
incomplete += 1;
eprintln!("Unfinished: {:?}", report);
}
}
}
}
anyhow::ensure!(
incomplete == 0,
"{incomplete} fixture missions did not finish"
);
Ok(())
}
-73
View File
@@ -1,73 +0,0 @@
#!/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
@@ -1,73 +0,0 @@
#!/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
@@ -1,63 +0,0 @@
#!/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()