# HG changeset patch # User Link Mauve # Date 1780419976 -7200 # Node ID 2d60a14f4816fff85c2a3e14aab1c1a8fdc35c40 # Parent 8c2ef2d503c92a538951020bab0e0ecd05242ad3 python: Rewrite the main entrypoint in Rust This lets us progressively replace Python modules with Rust ones. Currently missing features include: - Saving replays - Networking code for cooperative mode - Reading a configuration file for options - Maybe more. But the base game is working, so yay! diff -r 8c2ef2d503c9 -r 2d60a14f4816 formats/src/th06/t6rp.rs --- a/formats/src/th06/t6rp.rs Tue Jun 02 16:39:21 2026 +0200 +++ b/formats/src/th06/t6rp.rs Tue Jun 02 19:06:16 2026 +0200 @@ -38,7 +38,9 @@ /// The hidden difficulty at the beginning of this level. pub difficulty: u8, - unknown: u32, + /// XXX + // TODO: Make non-pub. + pub unknown: u32, /// The list of keys pressed during this level. pub keys: Vec<(u32, u16, u16)>, diff -r 8c2ef2d503c9 -r 2d60a14f4816 python/Cargo.toml --- a/python/Cargo.toml Tue Jun 02 16:39:21 2026 +0200 +++ b/python/Cargo.toml Tue Jun 02 19:06:16 2026 +0200 @@ -12,6 +12,10 @@ crate-type = ["cdylib"] name = "touhou" +[[bin]] +name = "pytouhou" +path = "src/main.rs" + [dependencies] touhou-formats = "*" touhou-utils = { version = "*", path = "../utils" } @@ -19,6 +23,7 @@ glob = "0.3.3" kira = "0.12.0" png = "0.18.0" +clap = { version = "4.6.1", features = ["derive"] } [features] default = [] diff -r 8c2ef2d503c9 -r 2d60a14f4816 python/src/lib.rs --- a/python/src/lib.rs Tue Jun 02 16:39:21 2026 +0200 +++ b/python/src/lib.rs Tue Jun 02 19:06:16 2026 +0200 @@ -318,6 +318,11 @@ Prng { inner } } + #[getter] + fn seed(&self) -> u16 { + self.inner.get_seed() + } + fn rand_uint16(&mut self) -> u16 { self.inner.get_u16() } diff -r 8c2ef2d503c9 -r 2d60a14f4816 python/src/main.rs --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/python/src/main.rs Tue Jun 02 19:06:16 2026 +0200 @@ -0,0 +1,459 @@ +//! Main entrypoint for PyTouhou, now in Rust. + +// Copyright (C) 2011 Thibaut Girka +// Copyright (C) 2026 Link Mauve +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published +// by the Free Software Foundation; version 3 only. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +use clap::{Parser, ValueEnum}; +use pyo3::{prelude::*, types::PyDict}; +use std::{fs::File, path::PathBuf}; +use touhou_formats::th06::t6rp::{T6rp, Level}; + +#[derive(Debug, Clone, ValueEnum)] +enum Verbosity { + Debug, + Info, + Warning, + Error, + Critical, +} + +#[derive(Debug, Clone, ValueEnum)] +enum Game { + EoSD, + Sample, +} + +#[derive(Debug, Clone, ValueEnum)] +enum Frontend { + Sdl, + Glfw, +} + +impl Frontend { + const fn to_str(&self) -> &'static str { + match self { + Frontend::Sdl => "sdl", + Frontend::Glfw => "glfw", + } + } +} + +#[derive(Debug, Clone, ValueEnum)] +enum Backend { + OpenGL, + Glide, + Sdl, +} + +impl Backend { + const fn to_str(&self) -> &'static str { + match self { + Backend::OpenGL => "opengl", + Backend::Glide => "glide", + Backend::Sdl => "sdl", + } + } +} + +#[derive(Debug, Clone, ValueEnum)] +enum GlFlavor { + Core, + Es, + Compatibility, + Legacy, +} + +impl GlFlavor { + const fn to_str(&self) -> &'static str { + match self { + GlFlavor::Core => "core", + GlFlavor::Es => "es", + GlFlavor::Compatibility => "compatibility", + GlFlavor::Legacy => "legacy", + } + } +} + +/// Libre reimplementation of the Touhou 6 engine. +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Options { + /// Game’s data files. + #[arg(short, long/*, default_value_t = vec!["CM.DAT", "ST.DAT", "IN.DAT", "MD.DAT", "102h.exe"]*/)] + data: Vec, + + /// Game directory path. + #[arg(short, long/*, default_value_t = PathBuf::from(".")*/)] + path: Option, + + /// Set unlimited continues, and perhaps other debug features. + #[arg(long)] + debug: bool, + + /// Select the wanted logging level. + #[arg(long, value_enum, default_value_t = Verbosity::Warning)] + verbosity: Verbosity, + + /// Stage, 1 to 7 (Extra), nothing means story mode. + #[arg(short, long)] + stage: Option, + + /// Rank, from 0 (Easy, default) to 3 (Lunatic). + #[arg(short, long, default_value_t = 0)] + rank: u8, + + /// Select the character to use, from 0 (ReimuA, default) to 3 (MarisaB). + #[arg(short, long, default_value_t = 0)] + character: u8, + + /// Fight only bosses. + #[arg(short, long)] + boss_rush: bool, + + /// Select the game engine to use. + #[arg(long, value_enum, default_value_t = Game::EoSD)] + game: Game, + + /// Select the interface to use. + #[arg(long, value_enum, default_value_t = Game::EoSD)] + interface: Game, + + /// Hints file, to display text while playing. + #[arg(long)] + hints: Option, + + /// Select a file to replay. + #[arg(long)] + replay: Option, + + /// Save the upcoming game into a replay file. + #[arg(long)] + save_replay: Option, + + /// Skip the replay and start to play when it’s finished. + #[arg(long)] + skip_replay: bool, + + /// Local port to use. + #[arg(long, default_value_t = 0)] + port: u16, + + /// Remote address. + #[arg(long)] + remote: Option, + + /// Allow friendly-fire during netplay. + #[arg(long)] + friendly_fire: bool, + + /// Which windowing library to use (glfw or sdl). + #[arg(long, value_enum, default_value_t = Frontend::Glfw)] + frontend: Frontend, + + /// Which backend to use (opengl, glide or sdl). + #[arg(long, value_enum, default_value_t = Backend::OpenGL)] + backend: Backend, + + /// Set fps limit. A value of 0 disables fps limiting, while a negative value limits to 60 fps if and only if vsync doesn’t work. + #[arg(long, default_value_t = -1)] + fps_limit: i16, + + /// Set the frameskip, as 1/FRAMESKIP, or disabled if 0. + #[arg(long, default_value_t = 1)] + frameskip: i8, + + /// Disable background display (huge performance boost on slow systems). + #[arg(long)] + no_background: bool, + + /// Disable particles handling (huge performance boost on slow systems). + #[arg(long)] + no_particles: bool, + + /// Disable music and sound effects. + #[arg(long)] + no_sound: bool, + + /// OpenGL profile to use. + #[arg(long, value_enum, default_value_t = GlFlavor::Compatibility)] + gl_flavor: GlFlavor, + + /// OpenGL version to use. + #[arg(long, default_value_t = 2.1)] + gl_version: f32, + + /// Enable double buffering. + #[arg(long)] + double_buffer: Option, +} + +fn main() -> PyResult<()> { + let mut args = Options::parse(); + if args.data.is_empty() { + args.data = vec!["CM.DAT".into(), "ST.DAT".into(), "IN.DAT".into(), "MD.DAT".into(), "102h.exe".into()]; + } + if args.path.is_none() { + args.path = Some(".".into()); + } + + Python::initialize(); + + Python::attach(|py| { + let game = PyModule::import(py, "pytouhou.games.eosd.game").unwrap(); + let Common = game.getattr("Common").unwrap(); + let Game = game.getattr("Game").unwrap(); + + let interface = PyModule::import(py, "pytouhou.games.eosd.interface").unwrap(); + let Interface = interface.getattr("Interface").unwrap(); + let width: usize = Interface.getattr("width").unwrap().extract().unwrap(); + let height: usize = Interface.getattr("height").unwrap().extract().unwrap(); + + let backend = PyModule::import(py, "pytouhou.ui.opengl.backend").unwrap(); + let backend_init = backend.getattr("init").unwrap(); + let options = PyDict::new(py); + options.set_item("flavor", args.gl_flavor.to_str()).unwrap(); + options.set_item("version", args.gl_version.to_string()).unwrap(); + options.set_item("double-buffer", args.double_buffer).unwrap(); + options.set_item("frontend", args.frontend.to_str()).unwrap(); + backend_init.call1((options,)).unwrap(); + let GameRenderer = backend.getattr("GameRenderer").unwrap(); + + let window = PyModule::import(py, "pytouhou.ui.window").unwrap(); + let Window = window.getattr("Window").unwrap(); + let window = Window.call1((backend, width, height, args.fps_limit, args.frameskip)).unwrap(); + + let gamerunner = PyModule::import(py, "pytouhou.ui.gamerunner").unwrap(); + let GameRunner = gamerunner.getattr("GameRunner").unwrap(); + + let loader = PyModule::import(py, "pytouhou.resource.loader").unwrap(); + let Loader = loader.getattr("Loader").unwrap(); + let resource_loader = Loader.call1((args.path,)).unwrap(); + + let scan_archives = resource_loader.getattr("scan_archives").unwrap(); + let data: Vec<_> = args.data.into_iter().map(|x| x.display().to_string()).collect(); + if let Err(err) = scan_archives.call1((data,)) { + eprintln!("Some data files were not found, did you forget the -p option?"); + eprintln!("{err}"); + std::process::exit(1); + } + + let libtouhou = PyModule::import(py, "libtouhou").unwrap(); + let Prng = libtouhou.getattr("Prng").unwrap(); + + let mut stage_num; + let story; + let mut continues; + if let Some(stage) = args.stage { + stage_num = stage as usize; + story = false; + continues = 0; + } else { + stage_num = 1; + story = true; + continues = 3; + } + + if args.debug { + continues = -1; // Infinite lives + } + + let mut replay = None; + let rank; + let character; + if let Some(replay_path) = args.replay { + let data = std::fs::read(replay_path).unwrap(); + let (encrypted, header) = T6rp::parse_header(&data).unwrap(); + let decrypted = T6rp::decrypt(header.key, encrypted); + assert!(T6rp::verify(header.key, header.checksum, &decrypted)); + let (_, replay2) = T6rp::from_slice(&decrypted).unwrap(); + replay = Some(replay2); + rank = header.rank; + character = header.character; + } else { + rank = args.rank; + character = args.character; + } + + let mut save_keystates: Option> = None; + /* + if let Some(save_filename) = args.save_replay { + let save_replay = T6RP(); + save_replay.rank = rank; + save_replay.character = character; + } + */ + + let mut difficulty = 16; + + let selected_player; + let characters; + let con; + /* + let addr; + let prng; + if args.port != 0 { + if let Some(remote) = args.remote { + let (remote_addr, remote_port) = remote.split_once(':').unwrap(); + addr = Some((remote_addr, u16::from_str_radix(remote_port, 10))); + selected_player = 0; + } else { + addr = None; + selected_player = 1; + } + + prng = Prng::new(0); + con = Network(args.port, addr, selected_player); + characters = [1, 3]; + } else { + */ + con = None::; + selected_player = 0; + characters = [character]; + /* + } + */ + + if let Some(hints_filename) = args.hints { + let data = std::fs::read(hints_filename).unwrap(); + //let hints = Hint.read(data); + } + + let game_class = Game/*if args.boss_rush { GameBossRush } else { Game }*/; + + let common = Common.call1((resource_loader.clone(), characters, continues)).unwrap(); + let first_player = common.getattr("players").unwrap().get_item(0).unwrap(); + let interface = Interface.call1((resource_loader.clone(), first_player.clone())).unwrap(); + common.setattr("interface", interface).unwrap(); + let renderer = if !GameRenderer.is_none() { + Some(GameRenderer.call1((resource_loader.clone(), window.clone())).unwrap()) + } else { + None + }; + let runner = GameRunner.call1((window.clone(), renderer, common.clone(), resource_loader.clone(), args.skip_replay, con, args.no_sound)).unwrap(); + let window_set_runner = window.getattr("set_runner").unwrap(); + window_set_runner.call1((runner.clone(),)).unwrap(); + + loop { + let prng; + if let Some(replay) = &replay { + let Some(level) = &replay.levels[stage_num - 1] else { + break; + }; + + prng = Prng.call1((level.random_seed,)).unwrap(); + + // TODO: apply the replay to the other players. + // TODO: see if the stored score is used or if it’s the one from the previous stage. + if stage_num != 1 && replay.levels[stage_num - 2].is_some() { + if let Some(previous_level) = &replay.levels[stage_num - 1] { + first_player.setattr("score", previous_level.score).unwrap(); + first_player.setattr("effective_score", previous_level.score).unwrap(); + } + } + first_player.setattr("points", level.point_items).unwrap(); + first_player.setattr("power", level.power).unwrap(); + first_player.setattr("lives", level.lives).unwrap(); + first_player.setattr("bombs", level.bombs).unwrap(); + difficulty = level.difficulty; + } else if args.port == 0 { + prng = Prng.call0().unwrap(); + } else { + panic!("TODO: Remove that branch!"); + } + + if args.save_replay.is_some() { + if replay.is_none() { + let keys = Vec::new(); + let level = Level { + score: first_player.getattr("score").unwrap().extract().unwrap(), + random_seed: prng.getattr("seed").unwrap().extract().unwrap(), + point_items: first_player.getattr("points").unwrap().extract().unwrap(), + power: first_player.getattr("power").unwrap().extract().unwrap(), + lives: first_player.getattr("lives").unwrap().extract().unwrap(), + bombs: first_player.getattr("bombs").unwrap().extract().unwrap(), + difficulty, + unknown: 0, + keys, + }; + //save_replay.levels[stage_num - 1] = level; + } + save_keystates = Some(Vec::new()); + } + + let hints_stage = if false/*hints*/ { + Some(true) // hints.stages[stage_num - 1] + } else { + None + }; + + let game = game_class.call1((resource_loader.clone(), stage_num, args.rank, difficulty, + common.clone(), prng.clone(), hints_stage, args.friendly_fire)).unwrap(); + + if args.no_particles { + let new_particle = pyo3::types::PyCFunction::new_closure(py, Some(c"new_particle"), None, |_, _| ()).unwrap(); + game.setattr("new_particle", new_particle).unwrap(); + } + + let background = if args.no_background { + None + } else { + Some(game.getattr("background").unwrap()) + }; + let runner_load_game = runner.getattr("load_game").unwrap(); + let game_std = game.getattr("std").unwrap(); + let game_std_bgms = game_std.getattr("bgms").unwrap(); + runner_load_game.call1((game, background, game_std_bgms, None::/*replay*/, save_keystates.clone())).unwrap(); + + // Main loop + let window_run = window.getattr("run").unwrap(); + match window_run.call0() { + Ok(_) => break, + ref orig_err @ Err(ref err) => { + match err.get_type(py).to_string().as_str() { + "" => { + if !story || stage_num == (if false/*boss_rush*/ { 7 } else if rank > 0 { 6 } else { 5 }) { + break; + } + stage_num += 1; + } + "" => { + println!("Game over!"); + break; + } + err => { + eprintln!("Got unexpected exception {err:?}!"); + orig_err.as_ref().unwrap(); + } + + } + } + /* + finally: + if save_filename: + last_key = -1 + for time, key in enumerate(save_keystates): + if key != last_key: + level.keys.append((time, key, 0)) + last_key = key + */ + } + } + + window_set_runner.call1((None::,)).unwrap(); + + if let Some(save_filename) = args.save_replay { + let file = File::create(save_filename).unwrap(); + //save_replay.write(file); + } + }); + Ok(()) +} diff -r 8c2ef2d503c9 -r 2d60a14f4816 pytouhou/game/game.pyx --- a/pytouhou/game/game.pyx Tue Jun 02 16:39:21 2026 +0200 +++ b/pytouhou/game/game.pyx Tue Jun 02 19:06:16 2026 +0200 @@ -599,3 +599,31 @@ def select_player_key(player): return (player.score, player.character) + + +class GameBossRush(Game): + def run_iter(self, keystates): + for i in range(20): + skip = not (self.enemies or self.items or self.lasers + or self.bullets or self.cancelled_bullets) + if skip: + keystates = [k & ~1 for k in keystates] + Game.run_iter(self, [0 if i else k | 256 for k in keystates]) + if not self.enemies and self.frame % 90 == 0: + for player in self.players: + if player.power < 128: + player.power += 1 + if not skip: + break + + def cleanup(self): + boss_wait = any(ecl_runner.boss_wait for ecl_runner in self.ecl_runners) + if not (self.boss or self.msg_wait or boss_wait): + self.enemies = [enemy for enemy in self.enemies + if enemy.boss_callback or enemy.frame > 1] + for laser in self.lasers: + if laser.frame <= 1: + laser.removed = True + self.lasers = [laser for laser in self.lasers if laser.frame > 1] + self.bullets = [bullet for bullet in self.bullets if bullet.frame > 1] + Game.cleanup(self) diff -r 8c2ef2d503c9 -r 2d60a14f4816 pytouhou/options.py --- a/pytouhou/options.py Tue Jun 02 16:39:21 2026 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,158 +0,0 @@ -# -*- encoding: utf-8 -*- -## -## Copyright (C) 2014 Emmanuel Gil Peyrot -## -## This program is free software; you can redistribute it and/or modify -## it under the terms of the GNU General Public License as published -## by the Free Software Foundation; version 3 only. -## -## This program is distributed in the hope that it will be useful, -## but WITHOUT ANY WARRANTY; without even the implied warranty of -## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -## GNU General Public License for more details. -## - -import os -from configparser import RawConfigParser, NoOptionError - -from pytouhou.utils.xdg import load_config_paths, save_config_path - - -class Options: - def __init__(self, name, defaults): - load_paths = list(reversed([os.path.join(directory, '%s.cfg' % name) - for directory - in load_config_paths(name)])) - self.save_path = os.path.join(save_config_path(name), '%s.cfg' % name) - - self.config = RawConfigParser(defaults) - self.paths = self.config.read(load_paths) - self.section = name if self.config.has_section(name) else 'DEFAULT' - - def get(self, option): - try: - return self.config.get(self.section, option) - except NoOptionError: - return None - - def set(self, option, value): - if value is not None: - self.config.set(self.section, option, value) - else: - self.config.remove_option(self.section, option) - - defaults = self.config._defaults - self.config._defaults = None - with open(self.save_path, 'w') as save_file: - self.config.write(save_file) - self.config._defaults = defaults - - -def patch_argument_parser(): - from argparse import ArgumentParser, _ActionsContainer - - original_method = _ActionsContainer.add_argument - - def add_argument(self, *args, **kwargs): - if 'default' not in kwargs: - dest = kwargs.get('dest') - if dest is None: - for dest in args: - dest = dest.lstrip('-') - value = self.default.get(dest) - if value is not None: - break - else: - dest = dest.replace('_', '-') - value = self.default.get(dest) - if value is not None: - argument_type = kwargs.get('type') - if argument_type is not None: - value = argument_type(value) - action = kwargs.get('action') - if action == 'store_true': - value = value.lower() == 'true' - elif action == 'store_false': - value = value.lower() != 'true' - if kwargs.get('nargs') == '*' and isinstance(value, str): - value = value.split() - kwargs['default'] = value - elif dest == 'double-buffer': - kwargs['default'] = None - return original_method(self, *args, **kwargs) - _ActionsContainer.add_argument = add_argument - - class Parser(ArgumentParser): - def __init__(self, *args, **kwargs): - self.default = kwargs.pop('default') - ArgumentParser.__init__(self, *args, **kwargs) - - def add_argument_group(self, *args, **kwargs): - group = ArgumentParser.add_argument_group(self, *args, **kwargs) - group.default = self.default - group.add_argument_group = self.add_argument_group - group.add_mutually_exclusive_group = self.add_mutually_exclusive_group - return group - - def add_mutually_exclusive_group(self, *args, **kwargs): - group = ArgumentParser.add_mutually_exclusive_group(self, *args, **kwargs) - group.default = self.default - group.add_argument_group = self.add_argument_group - group.add_mutually_exclusive_group = self.add_mutually_exclusive_group - return group - - return Parser - - -ArgumentParser = patch_argument_parser() - - -def parse_config(section, defaults): - return Options(section, defaults) - - -def parse_arguments(defaults): - parser = ArgumentParser(description='Libre reimplementation of the Touhou 6 engine.', default=defaults) - - parser.add_argument('data', metavar='DAT', nargs='*', help='Game’s data files') - parser.add_argument('-p', '--path', metavar='DIRECTORY', help='Game directory path.') - parser.add_argument('--debug', action='store_true', help='Set unlimited continues, and perhaps other debug features.') - parser.add_argument('--verbosity', metavar='VERBOSITY', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], help='Select the wanted logging level.') - - game_group = parser.add_argument_group('Game options') - game_group.add_argument('-s', '--stage', metavar='STAGE', type=int, help='Stage, 1 to 7 (Extra), nothing means story mode.') - game_group.add_argument('-r', '--rank', metavar='RANK', type=int, help='Rank, from 0 (Easy, default) to 3 (Lunatic).') - game_group.add_argument('-c', '--character', metavar='CHARACTER', type=int, help='Select the character to use, from 0 (ReimuA, default) to 3 (MarisaB).') - game_group.add_argument('-b', '--boss-rush', action='store_true', help='Fight only bosses.') - game_group.add_argument('--game', metavar='GAME', help='Select the game engine to use.') - game_group.add_argument('--interface', metavar='INTERFACE', help='Select the interface to use.') - game_group.add_argument('--hints', metavar='HINTS', help='Hints file, to display text while playing.') - - replay_group = parser.add_argument_group('Replay options') - replay_group.add_argument('--replay', metavar='REPLAY', help='Select a file to replay.') - replay_group.add_argument('--save-replay', metavar='REPLAY', help='Save the upcoming game into a replay file.') - replay_group.add_argument('--skip-replay', action='store_true', help='Skip the replay and start to play when it’s finished.') - - netplay_group = parser.add_argument_group('Netplay options') - netplay_group.add_argument('--port', metavar='PORT', type=int, help='Local port to use.') - netplay_group.add_argument('--remote', metavar='REMOTE', help='Remote address.') - netplay_group.add_argument('--friendly-fire', action='store_true', help='Allow friendly-fire during netplay.') - - graphics_group = parser.add_argument_group('Graphics options') - graphics_group.add_argument('--frontend', metavar='FRONTEND', choices=['glfw', 'sdl'], help='Which windowing library to use (glfw or sdl).') - graphics_group.add_argument('--backend', metavar='BACKEND', choices=['opengl', 'glide', 'sdl'], nargs='*', help='Which backend to use (opengl, glide or sdl).') - graphics_group.add_argument('--fps-limit', metavar='FPS', type=int, help='Set fps limit. A value of 0 disables fps limiting, while a negative value limits to 60 fps if and only if vsync doesn’t work.') - graphics_group.add_argument('--frameskip', metavar='FRAMESKIP', type=int, help='Set the frameskip, as 1/FRAMESKIP, or disabled if 0.') - graphics_group.add_argument('--no-background', action='store_false', help='Disable background display (huge performance boost on slow systems).') - graphics_group.add_argument('--no-particles', action='store_false', help='Disable particles handling (huge performance boost on slow systems).') - graphics_group.add_argument('--no-sound', action='store_false', help='Disable music and sound effects.') - - opengl_group = parser.add_argument_group('OpenGL backend options') - opengl_group.add_argument('--gl-flavor', choices=['core', 'es', 'compatibility', 'legacy'], help='OpenGL profile to use.') - opengl_group.add_argument('--gl-version', type=float, help='OpenGL version to use.') - - double_buffer = opengl_group.add_mutually_exclusive_group() - double_buffer.add_argument('--double-buffer', dest='double_buffer', action='store_true', help='Enable double buffering.') - double_buffer.add_argument('--single-buffer', dest='double_buffer', action='store_false', help='Disable double buffering.') - - return parser.parse_args() diff -r 8c2ef2d503c9 -r 2d60a14f4816 pytouhou/utils/xdg.py --- a/pytouhou/utils/xdg.py Tue Jun 02 16:39:21 2026 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,55 +0,0 @@ -# -*- encoding: utf-8 -""" -This module is based on a rox module (LGPL): - -http://cvs.sourceforge.net/viewcvs.py/rox/ROX-Lib2/python/rox/basedir.py?rev=1.9&view=log - -The freedesktop.org Base Directory specification provides a way for -applications to locate shared data and configuration: - - http://standards.freedesktop.org/basedir-spec/ - -(based on version 0.6) - -This module can be used to load and save from and to these directories. - -Typical usage: - - from rox import basedir - - for dir in basedir.load_config_paths('mydomain.org', 'MyProg', 'Options'): - print "Load settings from", dir - - dir = basedir.save_config_path('mydomain.org', 'MyProg') - print >>file(os.path.join(dir, 'Options'), 'w'), "foo=2" - -Note: see the rox.Options module for a higher-level API for managing options. -""" - -import os - -_home = os.path.expanduser('~') -xdg_config_home = os.environ.get('XDG_CONFIG_HOME') or \ - os.path.join(_home, '.config') - -xdg_config_dirs = [xdg_config_home] + \ - (os.environ.get('XDG_CONFIG_DIRS') or '/etc/xdg').split(':') - -xdg_config_dirs = [x for x in xdg_config_dirs if x] - - -def save_config_path(*resource): - resource = os.path.join(*resource) - assert not resource.startswith('/') - path = os.path.join(xdg_config_home, resource) - if not os.path.isdir(path): - os.makedirs(path, 0o700) - return path - - -def load_config_paths(*resource): - resource = os.path.join(*resource) - for config_dir in xdg_config_dirs: - path = os.path.join(config_dir, resource) - if os.path.exists(path): - yield path diff -r 8c2ef2d503c9 -r 2d60a14f4816 scripts/pytouhou --- a/scripts/pytouhou Tue Jun 02 16:39:21 2026 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,292 +0,0 @@ -#!/usr/bin/env python3 -# -*- encoding: utf-8 -*- -## -## Copyright (C) 2011 Thibaut Girka -## -## This program is free software; you can redistribute it and/or modify -## it under the terms of the GNU General Public License as published -## by the Free Software Foundation; version 3 only. -## -## This program is distributed in the hope that it will be useful, -## but WITHOUT ANY WARRANTY; without even the implied warranty of -## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -## GNU General Public License for more details. -## - -from os.path import pathsep -default_data = (pathsep.join(('CM.DAT', 'th06*_CM.DAT', '*CM.DAT', '*cm.dat')), - pathsep.join(('ST.DAT', 'th6*ST.DAT', '*ST.DAT', '*st.dat')), - pathsep.join(('IN.DAT', 'th6*IN.DAT', '*IN.DAT', '*in.dat')), - pathsep.join(('MD.DAT', 'th6*MD.DAT', '*MD.DAT', '*md.dat')), - pathsep.join(('102h.exe', '102*.exe', '東方紅魔郷.exe', '*.exe'))) - -defaults = {'data': default_data, - 'path': '.', - 'rank': 0, - 'character': 0, - 'game': 'eosd', - 'interface': 'eosd', - 'port': 0, - 'frontend': 'glfw', - 'backend': ['opengl', 'sdl'], - 'gl-flavor': 'compatibility', - 'gl-version': 2.1, - 'double-buffer': None, - 'fps-limit': -1, - 'frameskip': 1} - -from pytouhou.options import parse_config, parse_arguments -options = parse_config('pytouhou', defaults) -args = parse_arguments(options) - -verbosity = args.verbosity or options.get('verbosity') or 'WARNING' - -import logging -logging.basicConfig(level=getattr(logging, verbosity), - format='[%(name)s] [%(levelname)s]: %(message)s') - -logger = logging -logger.root.name = 'pytouhou' - -logger.info('Configuration loaded from: %s', ', '.join(options.paths)) - -import sys -from importlib import import_module - -def load_module(type_, name, items=None): - try: - module = import_module('pytouhou.games.%s.%s' % (name, type_)) - except ImportError: - logger.exception('Module “%s” doesn’t contain %s data, aborting:', name, type_) - sys.exit(1) - if items is None: - return module - return (getattr(module, item) for item in items) - -Game, Common = load_module('game', args.game, ['Game', 'Common']) -Interface = load_module('interface', args.interface).Interface - -from pytouhou.lib.sdl import SDL, show_simple_message_box -from pytouhou.ui.window import Window -from pytouhou.resource.loader import Loader -from pytouhou.ui.gamerunner import GameRunner -from pytouhou.game import NextStage, GameOver -from pytouhou.formats.t6rp import T6RP, Level -from libtouhou import Prng as Random -from pytouhou.formats.hint import Hint -from pytouhou.network import Network - - -for backend_name in args.backend: - if backend_name == 'opengl': - options = { - 'flavor': args.gl_flavor, - 'version': args.gl_version, - 'double-buffer': args.double_buffer, - 'frontend': args.frontend, - } - else: - options = {} - - try: - backend = import_module('pytouhou.ui.%s.backend' % backend_name) - except ImportError: - logger.exception('Failed to import backend %s:', backend_name) - continue - - try: - backend.init(options) - except Exception: - logger.exception('Backend %s failed to initialize:', backend_name) - continue - - GameRenderer = backend.GameRenderer - break -else: - show_simple_message_box(u'No graphical backend could be used, continuing with a windowless game.') - backend = None - GameRenderer = None - - -class GameBossRush(Game): - def run_iter(self, keystates): - for i in range(20): - skip = not (self.enemies or self.items or self.lasers - or self.bullets or self.cancelled_bullets) - if skip: - keystates = [k & ~1 for k in keystates] - Game.run_iter(self, [0 if i else k | 256 for k in keystates]) - if not self.enemies and self.frame % 90 == 0: - for player in self.players: - if player.power < 128: - player.power += 1 - if not skip: - break - - def cleanup(self): - boss_wait = any(ecl_runner.boss_wait for ecl_runner in self.ecl_runners) - if not (self.boss or self.msg_wait or boss_wait): - self.enemies = [enemy for enemy in self.enemies - if enemy.boss_callback or enemy.frame > 1] - for laser in self.lasers: - if laser.frame <= 1: - laser.removed = True - self.lasers = [laser for laser in self.lasers if laser.frame > 1] - self.bullets = [bullet for bullet in self.bullets if bullet.frame > 1] - Game.cleanup(self) - - -def main(window, path, data, stage_num, rank, character, replay, save_filename, - skip_replay, boss_rush, debug, enable_background, enable_particles, - hints, port, remote, friendly_fire): - - resource_loader = Loader(path) - - try: - resource_loader.scan_archives(data) - except IOError: - show_simple_message_box(u'Some data files were not found, did you forget the -p option?') - sys.exit(1) - - if stage_num is None: - story = True - stage_num = 1 - continues = 3 - else: - story = False - continues = 0 - - if debug: - continues = -1 # Infinite lives - - if replay: - with open(replay, 'rb') as file: - replay = T6RP.read(file) - rank = replay.rank - character = replay.character - - save_keystates = None - if save_filename: - save_replay = T6RP() - save_replay.rank = rank - save_replay.character = character - - difficulty = 16 - - if port != 0: - if remote: - remote_addr, remote_port = remote.split(':') - addr = remote_addr, int(remote_port) - selected_player = 0 - else: - addr = None - selected_player = 1 - - prng = Random(0) - con = Network(port, addr, selected_player) - characters = [1, 3] - else: - con = None - selected_player = 0 - characters = [character] - - if hints: - with open(hints, 'rb') as file: - hints = Hint.read(file) - - game_class = GameBossRush if boss_rush else Game - - common = Common(resource_loader, characters, continues) - interface = Interface(resource_loader, common.players[0]) #XXX - common.interface = interface #XXX - renderer = GameRenderer(resource_loader, window) if GameRenderer is not None else None - runner = GameRunner(window, renderer, common, resource_loader, skip_replay, con, args.no_sound) - window.set_runner(runner) - - while True: - first_player = common.players[0] - - if replay: - level = replay.levels[stage_num - 1] - if not level: - raise Exception - - prng = Random(level.random_seed) - - #TODO: apply the replay to the other players. - #TODO: see if the stored score is used or if it’s the one from the previous stage. - if stage_num != 1 and stage_num - 2 in replay.levels: - previous_level = replay.levels[stage_num - 1] - first_player.score = previous_level.score - first_player.effective_score = previous_level.score - first_player.points = level.point_items - first_player.power = level.power - first_player.lives = level.lives - first_player.bombs = level.bombs - difficulty = level.difficulty - elif port == 0: - prng = Random() - - if save_filename: - if not replay: - save_replay.levels[stage_num - 1] = level = Level() - level.random_seed = prng.seed - level.score = first_player.score - level.point_items = first_player.points - level.power = first_player.power - level.lives = first_player.lives - level.bombs = first_player.bombs - level.difficulty = difficulty - save_keystates = [] - - hints_stage = hints.stages[stage_num - 1] if hints else None - - game = game_class(resource_loader, stage_num, rank, difficulty, - common, prng, hints_stage, friendly_fire) - - if not enable_particles: - def new_particle(pos, anim, amp, number=1, reverse=False, duration=24): - pass - game.new_particle = new_particle - - background = game.background if enable_background else None - runner.load_game(game, background, game.std.bgms, replay, save_keystates) - - try: - # Main loop - window.run() - break - except NextStage: - if not story or stage_num == (7 if boss_rush else 6 if rank > 0 else 5): - break - stage_num += 1 - except GameOver: - show_simple_message_box(u'Game over!') - break - finally: - if save_filename: - last_key = -1 - for time, key in enumerate(save_keystates): - if key != last_key: - level.keys.append((time, key, 0)) - last_key = key - - window.set_runner(None) - - if save_filename: - with open(save_filename, 'wb+') as file: - save_replay.write(file) - - -is_sdl = (args.frontend == 'sdl') -with SDL(video=is_sdl): - window = Window(backend, Interface.width, Interface.height, - fps_limit=args.fps_limit, frameskip=args.frameskip) - - main(window, args.path, tuple(args.data), args.stage, args.rank, - args.character, args.replay, args.save_replay, args.skip_replay, - args.boss_rush, args.debug, args.no_background, args.no_particles, - args.hints, args.port, args.remote, args.friendly_fire) - - import gc - gc.collect() diff -r 8c2ef2d503c9 -r 2d60a14f4816 setup.py --- a/setup.py Tue Jun 02 16:39:21 2026 +0200 +++ b/setup.py Tue Jun 02 19:06:16 2026 +0200 @@ -166,8 +166,7 @@ if sys.platform == 'win32': nthreads = None # It seems Windows can’t compile in parallel. base = 'Win32GUI' - extra = {'options': {'build_exe': {'includes': [mod.name for mod in ext_modules] + py_modules}}, - 'executables': [Executable(script='scripts/pytouhou', base=base)]} + extra = {'options': {'build_exe': {'includes': [mod.name for mod in ext_modules] + py_modules}}} # Create a link to the data files (for packaging purposes) @@ -193,7 +192,7 @@ 'MAX_ELEMENTS': 640 * 4 * 3, 'MAX_SOUNDS': 26, 'USE_OPENGL': use_opengl}), - scripts=['scripts/pytouhou'] + (['scripts/anmviewer'] if anmviewer else []), + scripts=['scripts/anmviewer'] if anmviewer else [], packages=['pytouhou'], **extra) diff -r 8c2ef2d503c9 -r 2d60a14f4816 utils/src/prng.rs --- a/utils/src/prng.rs Tue Jun 02 16:39:21 2026 +0200 +++ b/utils/src/prng.rs Tue Jun 02 19:06:16 2026 +0200 @@ -16,6 +16,11 @@ } } + /// Returns the current seed. + pub fn get_seed(&self) -> u16 { + self.seed + } + /// Generates a pseudo-random u16. /// /// RE’d from 102h.exe@0x41e780