view python/src/main.rs @ 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
children
line wrap: on
line source

//! 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(())
}