initial rust commit

This commit is contained in:
ppc
2026-01-05 21:32:38 +00:00
parent 563362eea5
commit 96a9e2a498
8 changed files with 1213 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
use std::{sync::{Mutex, OnceLock}, time::{Duration, Instant}};
use crate::utils::{parse_access_code, parse_idm_hex};
#[derive(Debug, Clone)]
pub (in crate) struct StoredCard {
pub access_code: [u8; 10],
pub card_idm: Option<[u8; 8]>,
pub expiry_time: Instant
}
impl StoredCard {
pub fn is_expired(&self) -> bool {
Instant::now() > self.expiry_time
}
}
pub enum CardPresentResult {
Accepted,
Rejected(String /* reason */),
SlotUnavailable(Instant /* release time */),
}
static PRESENTER: OnceLock<CardPresenter> = OnceLock::new();
pub (in crate) struct CardPresenter {
current_card: Mutex<Option<StoredCard>>,
}
impl Default for CardPresenter {
fn default() -> Self {
Self {
current_card: Mutex::new(None),
}
}
}
impl CardPresenter {
pub fn instance() -> &'static CardPresenter {
PRESENTER.get_or_init(|| CardPresenter::default())
}
pub fn request_card(&mut self, require_idm: Option<bool>) -> Option<StoredCard> {
let mut guard = self.current_card.lock().unwrap();
let card = guard.as_ref()?;
if let Some(require) = require_idm {
if card.card_idm.is_some() != require {
return None;
}
}
guard.take()
}
pub fn present_card(&self, access_code: &String, idm_hex: Option<&String>, validity: Duration) -> CardPresentResult {
let mut guard = self.current_card.lock().unwrap();
if let Some(card) = guard.as_ref() && !card.is_expired() {
return CardPresentResult::SlotUnavailable(card.expiry_time);
}
let access_code_bytes = match parse_access_code(access_code) {
Some(bytes) => bytes,
None => return CardPresentResult::Rejected("Invalid access code format".to_string()),
};
let mut card_idm_bytes = None;
if let Some(idm_hex) = idm_hex {
match parse_idm_hex(idm_hex) {
Some(bytes) => card_idm_bytes = Some(bytes),
None => return CardPresentResult::Rejected("Invalid IDm format".to_string()),
}
}
let stored_card = StoredCard {
access_code: access_code_bytes,
card_idm: card_idm_bytes,
expiry_time: Instant::now() + validity,
};
*guard = Some(stored_card);
CardPresentResult::Accepted
}
}
+108
View File
@@ -0,0 +1,108 @@
use std::time::Instant;
use axum::{Json, http::{StatusCode, header}, response::{IntoResponse, Response}};
use serde::{Deserialize, Serialize};
use crate::{amcard::{CardPresentResult, CardPresenter}, config::Config, metrics::SystemMetrics};
pub const API_VERSION: u8 = 1;
#[derive(Serialize)]
struct ServerError {
error: String,
}
#[derive(Serialize)]
struct ServerState {
#[serde(rename = "apiVersion")]
api_version: u8,
#[serde(rename = "gameId")]
game_id: Option<String>,
#[serde(rename = "serverName")]
server_name: String,
#[serde(rename = "sessionUptime")]
session_uptime: u64,
#[serde(rename = "timeSinceLastPoll")]
time_since_last_poll: u64,
}
pub fn server_state_handler() -> Json<ServerState> {
let config = Config::instance();
let metrics = SystemMetrics::instance();
let state = ServerState {
api_version: API_VERSION,
game_id: config.game_id.clone(),
server_name: config.server_name.clone(),
session_uptime: metrics.start_time.map_or(0, |start| start.elapsed().as_secs()),
time_since_last_poll: metrics.last_poll_time.map_or(0, |last_poll| last_poll.elapsed().as_secs()),
};
Json(state)
}
#[derive(Deserialize)]
struct CardSubmissionRequest {
#[serde(rename = "cardId")]
access_code: String,
#[serde(rename = "physicalCardIDm")]
card_idm_hex: Option<String>,
}
pub fn card_submission_handler(Json(payload): Json<CardSubmissionRequest>) -> impl IntoResponse {
if payload.access_code.len() > 20 || payload.access_code.len() == 0 {
return build_error_response(StatusCode::BAD_REQUEST, "Access code is required");
}
if payload.access_code.chars().any(|c| !c.is_ascii_digit()) {
return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid access code provided");
}
if payload.access_code.chars().all(|c| c == '0') {
return build_error_response(StatusCode::FORBIDDEN, "All-zero access codes are forbidden.");
}
if payload.card_idm_hex.as_ref().is_some_and(|idm| idm.chars().any(|c| !c.is_ascii_hexdigit())) {
return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid IDm provided");
}
let presenter = CardPresenter::instance().present_card(
&payload.access_code,
payload.card_idm_hex.as_ref(),
std::time::Duration::from_secs(5)
);
match presenter {
CardPresentResult::Accepted => StatusCode::ACCEPTED.into_response(),
CardPresentResult::Rejected(_) => StatusCode::FORBIDDEN.into_response(),
CardPresentResult::SlotUnavailable(release_time) => {
let now = Instant::now();
let wait_secs = if release_time > now {
(release_time - now).as_secs()
} else {
0
};
let mut response = build_error_response(StatusCode::TOO_MANY_REQUESTS, "A card has already been submitted. Please wait before submitting another.");
response.headers_mut().insert(
header::RETRY_AFTER,
wait_secs.to_string().parse().unwrap(),
);
response
}
}
}
fn build_error_response(status: StatusCode, message: &str) -> Response {
let error = ServerError {
error: message.to_string(),
};
(status, Json(error)).into_response()
}
+121
View File
@@ -0,0 +1,121 @@
use ini::Ini;
use log::error;
use std::env;
use std::path::PathBuf;
use std::sync::OnceLock;
const DEFAULT_CONFIG_FILE: &str = "./segatools.ini";
const CONFIG_FILE_OVERRIDE_ENV: &str = "SEGATOOLS_CONFIG_PATH";
const DEFAULT_SERVER_NAME: &str = "AMNET-PC";
const DEFAULT_AIME_FILE: &str = r"DEVICE\aime.txt";
const DEFAULT_AIME_KEYBOARD_ENABLED: bool = true;
const DEFAULT_INCLUDE_IDM_FOR_PHYSICAL_CARDS: bool = false;
const GAME_ID_EXACT_LEN: usize = 4;
const SERVER_NAME_MAX_LEN: usize = 16;
static CONFIG: OnceLock<Config> = OnceLock::new();
pub (in crate) struct Config {
/// Game ID (4 characters or none)
pub game_id: Option<String>,
/// Server name shown on devices (max 16 chars). Defaults to the machine hostname.
pub server_name: String,
/// Whether physically scanned cards should have their IDm forwarded to the game
/// instead of the access code.
pub physical_card_use_idm: bool,
/// Whether holding the enter key should trigger a card scan using the code
/// from the aime.txt file
pub enable_aime_txt: bool,
/// Path to the aime.txt file
pub aime_txt_path: PathBuf,
}
impl Config {
pub fn instance() -> &'static Config {
CONFIG.get_or_init(|| {
Config::load().unwrap_or_else(|e| {
log::warn!("Failed to load config: {}. Using defaults.", e);
Config::default()
})
})
}
fn load() -> Result<Self, Box<dyn std::error::Error>> {
let config_path = env::var(CONFIG_FILE_OVERRIDE_ENV).unwrap_or_else(|_| DEFAULT_CONFIG_FILE.to_string());
let ini = Ini::load_from_file(&config_path).map_err(|e| format!("Failed to load config file '{}': {}", config_path, e))?;
let io_section = ini.section(Some("aimeio")).ok_or("Missing [aimeio] section")?;
let aime_section = ini.section(Some("aime"));
// gameId must be exactly 4 ascii letters or empty
let game_id = io_section.get("gameId").unwrap_or("");
let game_id = if game_id.is_empty() {
None
} else if game_id.chars().count() == GAME_ID_EXACT_LEN && game_id.chars().all(|c| c.is_ascii_alphabetic()) {
Some(game_id.to_ascii_uppercase().to_string())
} else {
error!("Invalid gameId '{game_id}': must be exactly {GAME_ID_EXACT_LEN} characters or empty");
None
};
let default_hostname = hostname::get()
.ok()
.and_then(|h| h.into_string().ok())
.unwrap_or_else(|| DEFAULT_SERVER_NAME.to_string());
let server_name = io_section.get("serverName")
.unwrap_or(&default_hostname)
.chars()
.take(SERVER_NAME_MAX_LEN)
.collect();
// Read boolean flags
let physical_card_use_idm = io_section.get("useAimeDBForPhysicalCards")
.and_then(|v| v.parse::<i32>().ok())
.map(|v| v > 0)
.unwrap_or(false);
let enable_aime_txt = io_section.get("enableKeyboardMode")
.and_then(|v| v.parse::<i32>().ok())
.map(|v| v > 0)
.unwrap_or(DEFAULT_AIME_KEYBOARD_ENABLED);
let aime_txt_path = aime_section
.and_then(|s| s.get("aimePath"))
.unwrap_or(DEFAULT_AIME_FILE)
.into();
Ok(Config {
game_id,
server_name,
physical_card_use_idm,
enable_aime_txt,
aime_txt_path,
})
}
}
impl Default for Config {
fn default() -> Self {
let default_hostname = hostname::get()
.ok()
.and_then(|h| h.into_string().ok())
.unwrap_or_else(|| DEFAULT_SERVER_NAME.to_string());
Config {
game_id: None,
server_name: default_hostname.chars().take(SERVER_NAME_MAX_LEN).collect(),
physical_card_use_idm: DEFAULT_INCLUDE_IDM_FOR_PHYSICAL_CARDS,
enable_aime_txt: DEFAULT_AIME_KEYBOARD_ENABLED,
aime_txt_path: PathBuf::from(DEFAULT_AIME_FILE),
}
}
}
+10
View File
@@ -0,0 +1,10 @@
mod config;
mod amcard;
mod amserver;
mod metrics;
mod utils;
#[unsafe(no_mangle)]
pub extern "C" fn say_hello() {
println!("Hello, world!");
}
+23
View File
@@ -0,0 +1,23 @@
use std::sync::OnceLock;
static METRICS: OnceLock<SystemMetrics> = OnceLock::new();
pub (in crate) struct SystemMetrics {
pub start_time: Option<std::time::Instant>,
pub last_poll_time: Option<std::time::Instant>,
}
impl Default for SystemMetrics {
fn default() -> Self {
Self {
start_time: None,
last_poll_time: None,
}
}
}
impl SystemMetrics {
pub fn instance() -> &'static SystemMetrics {
METRICS.get_or_init(|| SystemMetrics::default())
}
}
+38
View File
@@ -0,0 +1,38 @@
pub (in crate) fn format_hex_bytes(bytes: &[u8]) -> String {
bytes.chunks(2)
.map(|chunk| chunk.iter().map(|b| format!("{:02X}", b)).collect::<String>())
.collect::<Vec<_>>()
.join(" ")
}
pub (in crate) fn parse_access_code(code: &String) -> Option<[u8; 10]> {
let matrix_code = format!("{:0>20}", code.replace(" ", ""));
let mut bytes: [u8; 10] = [0; 10];
for i in 0..bytes.len() {
let slice_str = &matrix_code[i*2..i*2+2];
let slice_val = u8::from_str_radix(slice_str, 16).ok()?;
bytes[i] = slice_val;
}
Some(bytes)
}
pub (in crate) fn parse_idm_hex(idm_hex: &String) -> Option<[u8; 8]> {
if idm_hex.len() > 16 || !idm_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let padded_idm = format!("{:0>16}", idm_hex);
let mut idm_bytes: [u8; 8] = [0; 8];
for i in 0..idm_bytes.len() {
let slice_str = &padded_idm[i*2..i*2+2];
let slice_val = u8::from_str_radix(slice_str, 16).ok()?;
idm_bytes[i] = slice_val;
}
Some(idm_bytes)
}