Package RustRaptor and refine controls, menus, saves, sound and mod selection
This commit is contained in:
1 parent
0844f6dffa
commit
e17bc1003e
649 files changed
+8652
-2677
No files matched your search
@@ -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 = ®istry.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(®istry, 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(())
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user