changeset 795:2d60a14f4816 default tip

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!
author Link Mauve <linkmauve@linkmauve.fr>
date Tue, 02 Jun 2026 19:06:16 +0200
parents 8c2ef2d503c9
children
files formats/src/th06/t6rp.rs python/Cargo.toml python/src/lib.rs python/src/main.rs pytouhou/game/game.pyx pytouhou/options.py pytouhou/utils/xdg.py scripts/pytouhou setup.py utils/src/prng.rs
diffstat 10 files changed, 507 insertions(+), 509 deletions(-) [+]
line wrap: on
line diff
--- 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)>,
--- 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 = []
--- 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()
     }
--- /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 <thib@sitedethib.com>
+// Copyright (C) 2026 Link Mauve <linkmauve@linkmauve.fr>
+//
+// 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<PathBuf>,
+
+    /// Game directory path.
+    #[arg(short, long/*, default_value_t = PathBuf::from(".")*/)]
+    path: Option<PathBuf>,
+
+    /// 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<u8>,
+
+    /// 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<String>,
+
+    /// Select a file to replay.
+    #[arg(long)]
+    replay: Option<String>,
+
+    /// Save the upcoming game into a replay file.
+    #[arg(long)]
+    save_replay: Option<String>,
+
+    /// 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<String>,
+
+    /// 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<bool>,
+}
+
+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<Vec<u8>> = 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::<u32>;
+            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::<u32>/*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() {
+                        "<class 'pytouhou.game.NextStage'>" => {
+                            if !story || stage_num == (if false/*boss_rush*/ { 7 } else if rank > 0 { 6 } else { 5 }) {
+                                break;
+                            }
+                            stage_num += 1;
+                        }
+                        "<class 'pytouhou.game.GameOver'>" => {
+                            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::<u32>,)).unwrap();
+
+        if let Some(save_filename) = args.save_replay {
+            let file = File::create(save_filename).unwrap();
+            //save_replay.write(file);
+        }
+    });
+    Ok(())
+}
--- 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)
--- 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 <linkmauve@linkmauve.fr>
-##
-## 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()
--- 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
--- 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 <thib@sitedethib.com>
-##
-## 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()
--- 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)
 
--- 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