From fdf2ff03a7d22912671c3502ae4236390035f7ef Mon Sep 17 00:00:00 2001 From: raptorreplica Date: Sun, 11 Oct 2026 12:51:04 +1100 Subject: [PATCH] Package standalone RustRaptor and refine widescreen menus with classic cinematics --- .gitignore | 3 + RustRaptor/README.md | 15 +- RustRaptor/docs/menu-validation.md | 6 +- RustRaptor/docs/standalone-package.md | 48 ++++ RustRaptor/run.sh | 6 +- RustRaptor/src/desktop.rs | 364 +++++++++++++++++++++++++- RustRaptor/src/main.rs | 56 +++- RustRaptor/src/menu_art.rs | 64 +++-- RustRaptor/src/persistence.rs | 2 +- RustRaptor/src/render.rs | 8 +- RustRaptor/tools/assemble_runtime.py | 136 ++++++++++ 11 files changed, 667 insertions(+), 41 deletions(-) create mode 100644 RustRaptor/docs/standalone-package.md create mode 100644 RustRaptor/tools/assemble_runtime.py diff --git a/.gitignore b/.gitignore index ae386025..79f249c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Derived game data — extracted from the copyrighted originals, never commit data/ RustRaptor/data/ +RustRaptor/bin/ +RustRaptor/lib/ +RustRaptor/runtime-manifest.json # Vendored raptor build output raptor/build/ diff --git a/RustRaptor/README.md b/RustRaptor/README.md index 9ad1de55..49d0bc06 100644 --- a/RustRaptor/README.md +++ b/RustRaptor/README.md @@ -17,9 +17,17 @@ Or from this folder: cargo test --locked ``` -Supply the original game catalog in `../data/` or `RustRaptor/data/`. You can -select another catalog with `--data DIR`. No C++ checkout or build is needed to -build or play the Rust game. Some optional comparison tests use retained source. +The self-contained runtime uses `bin/raptor-rust`, its sibling `data/` catalog +and the bundled `mods/`. `./run.sh` launches that executable without Cargo or +the old engine; paths remain valid after moving the folder. For development, +the launcher falls back to Cargo when no packaged executable is present. +`--data DIR` selects another catalog. Some optional comparison tests use retained source. + +Refresh the executable and copied original/approved-HD data with +`python3 tools/assemble_runtime.py`. Add `--zip dist/RustRaptor-linux-x86_64.zip` +to export a distributable folder containing the runtime, mods, source and API +documentation, excluding build caches and review drafts. See +[standalone packaging](docs/standalone-package.md). The **Mods** screen discovers the bundled `mods/` packages when no user mods folder exists. Enable **Iron Coast Campaign** to select all six mission mods, @@ -55,6 +63,7 @@ Existing untouched Rust default bindings migrate to these controls. | `tests/` | Portable fixtures and opt-in original-data checks | | `tools/` | Replay comparator, content-table generator and asset reference tools | | `docs/` | API, artwork, build and validation documentation | +| `bin/`, `data/`, `lib/` | Local standalone runtime and any bundled SDL2 library | | `target/` | Local Cargo builds, excluded from Git | All bundled mod definitions, scripts, SD/HD PNGs, WAVs, generation prompts and diff --git a/RustRaptor/docs/menu-validation.md b/RustRaptor/docs/menu-validation.md index e7ec945e..97065155 100644 --- a/RustRaptor/docs/menu-validation.md +++ b/RustRaptor/docs/menu-validation.md @@ -2,7 +2,11 @@ The full local-original-data audit captures 31 menu states and 11 frames of the original logos/intro. All 42 captures were inspected, including main menu, hangar, registration, shop purchase/sale details, equipment, missions, options, pilot slots, empty legacy archive, mod dependencies and disable confirmation, mismatched-save loading, pause and results. -The authored coastal-hangar background is packaged at `art/menu-background-v1.png`. Original bitmap labels remain sharp, panels preserve readable contrast, focus and feedback colors are consistent, and long text stays within panels. Main-menu and hangar actions stay visible together. The mission preview is outside the actionable list hitboxes. Shop cards display artwork, prices, resale value, quantity and automatic/selectable/passive behavior. +The authored coastal-hangar background is packaged at `art/menu-background-v1.png`. Menus use a 400×225 logical canvas at 16:9. Main-menu navigation occupies the open right side, with a compact pilot card above the visible ship; off-white labels and amber focus match the hangar lighting. Secondary screens retain their detailed classic layout centered within the wider background. Bitmap labels remain sharp and long text stays within panels. The mission preview is outside the actionable list hitboxes. Shop cards display artwork, prices, resale value, quantity and automatic/selectable/passive behavior. + +Gameplay uses the original 320×200 simulation canvas, presented at 4:3 with aspect correction enabled. At 1080p its viewport is 1440×1080, centered between subdued hangar sidebars. Original cinematics always use 4:3 with black borders, including when gameplay aspect correction is disabled. New profiles default to 1280×720; existing saved window sizes remain respected. + +The widescreen mouse check covers all eight main actions at 1280×720, 1280×800 and 1920×1080, secondary-screen offsets and clicks outside the viewport. The full 42-frame menu/intro audit was rerun after the layout change. A relocated packaged SDL executable also rendered menu and gameplay captures at 1280×800 and 1920×1080, with nonzero stereo audio captures. Those screenshots are under ignored `.reference/standalone-package/`. The audit also verifies the complete startup reaches its return screen at exactly 1,891 70 Hz service ticks and restores menu music. Cancelling a mod-mismatch prompt preserves the original save; accepting it displays unavailable equipment without silently discarding it. diff --git a/RustRaptor/docs/standalone-package.md b/RustRaptor/docs/standalone-package.md new file mode 100644 index 00000000..7edf1fdf --- /dev/null +++ b/RustRaptor/docs/standalone-package.md @@ -0,0 +1,48 @@ +# Standalone Linux package + +`RustRaptor/` contains the Rust executable, its original data catalog, approved +HD replacements, authored mods, SD/HD mod artwork, sound effects and music. +Normal play needs no C++ engine, Cargo, Python or source checkout. Run +`./run.sh`; installed pilots and configuration remain in the user's separate +`RaptorReplica` profile directories. + +The executable finds its package from `bin/raptor-rust`, including when launched +from another working directory. A missing packaged catalog reports an error +instead of selecting another checkout's data. `--data` and `--mods` remain +available for deliberate overrides. + +From this folder, run: + +```sh +python3 tools/assemble_runtime.py --zip dist/RustRaptor-linux-x86_64.zip +``` + +The assembler builds the desktop release, copies all catalog-listed files and +the exact currently approved HD manifest and referenced PNGs, installs the +binary and SDL2 shared library if linked, and checks the packaged catalog. +`runtime-manifest.json` records file hashes, catalog/HD counts and platform +dependencies. Unapproved HD drafts, reference screenshots and the Cargo build +cache are excluded from the zip. The exported `RustRaptor` folder can be moved +or unpacked anywhere, including paths containing spaces. + +The binary targets Linux x86_64. It still uses the operating system's C runtime +and desktop/audio drivers; this package is not a Windows or macOS executable. +Source, Cargo.lock, code licenses and mod API documentation are included. +Original and generated base-game data, local executables and runtime libraries +stay outside Git; the assembler and authored mod images/sounds are tracked. + +Re-run the assembler after engine changes, new approved HD registrations or +mod edits to refresh the package. The runtime snapshot contains approved art +only; it does not claim the full original HD catalog is complete. + +The current package was unpacked into a folder containing spaces and launched +from `/tmp` with only its bundled SDL2 library on the library search path. +Catalog validation checked 6,449 assets, 27 missions, 528 enemies, 16 weapons +and 24 items. All 22 bundled mods and all 221 approved HD frames validated. +Menu and gameplay screenshots at 1280×800 and 1920×1080, plus stereo audio +captures, completed successfully. Native `unzip` preserves the executable +permissions stored in the archive. +An original-intro capture at 1080p also confirms a 1440×1080 video area and +black 240-pixel side borders when gameplay stretching is enabled. Removing +the copied catalog produces an error at the copied data path, with no fallback +to the development checkout. diff --git a/RustRaptor/run.sh b/RustRaptor/run.sh index 25026cfd..5be45ff5 100755 --- a/RustRaptor/run.sh +++ b/RustRaptor/run.sh @@ -1,5 +1,9 @@ #!/bin/sh set -eu cd "$(dirname "$0")" -export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$HOME/.local/lib" +export LD_LIBRARY_PATH="$PWD/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +if [ -x "$PWD/bin/raptor-rust" ]; then + exec "$PWD/bin/raptor-rust" "$@" +fi +export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$HOME/.local/lib" exec cargo run --locked --release --manifest-path "$PWD/Cargo.toml" --bin raptor-rust -- "$@" diff --git a/RustRaptor/src/desktop.rs b/RustRaptor/src/desktop.rs index 81ba099b..a727e7bd 100644 --- a/RustRaptor/src/desktop.rs +++ b/RustRaptor/src/desktop.rs @@ -32,6 +32,12 @@ use std::{ time::{Duration, Instant}, }; +const MENU_WIDTH: usize = 400; +const MENU_HEIGHT: usize = 225; +const MENU_OFFSET: [i32; 2] = [40, 12]; +const MAIN_MENU_X: i32 = 248; +const MAIN_MENU_Y: i32 = 62; + impl AudioCallback for Mixer { type Channel = i16; fn callback(&mut self, out: &mut [i16]) { @@ -2711,6 +2717,144 @@ impl Host { } Ok(()) } + fn main_menu(&mut self, canvas: &mut Canvas) -> Result<()> { + use crate::menu_art::{ + MENU_ACCENT, MENU_CARD, MENU_FOCUS, MENU_MUTED, MENU_RULE, MENU_TEXT, + }; + + // The illustration leaves the right side open for navigation. Keep the + // ship and coastal view exposed instead of framing the entire screen. + let mut title = Canvas { + scale: canvas.scale * 2, + image: Sprite::blank( + self.menu_font.text_width("RAPTOR") * canvas.scale * 2, + self.menu_font.height() * canvas.scale * 2, + ), + }; + self.menu_font.text(&mut title, 0, 0, "RAPTOR", MENU_TEXT); + canvas.blit( + &title.image, + (MAIN_MENU_X + 10) * canvas.scale as i32, + 22 * canvas.scale as i32, + ); + self.font.text( + canvas, + MAIN_MENU_X + 10, + 43, + "CALL OF THE SHADOWS", + MENU_MUTED, + ); + canvas.rect(MAIN_MENU_X, MAIN_MENU_Y - 5, 136, 1, MENU_RULE); + for (index, label) in [ + "RETURN TO HANGAR", + "NEW PILOT", + "LOAD / IMPORT", + "OPTIONS", + "MODS", + "HELP / CREDITS", + "WATCH INTRO", + "QUIT", + ] + .iter() + .enumerate() + { + let y = MAIN_MENU_Y + index as i32 * 16; + if index == self.selected { + canvas.rect(MAIN_MENU_X, y, 136, 15, MENU_FOCUS); + canvas.rect(MAIN_MENU_X, y + 1, 2, 13, MENU_ACCENT); + } + self.menu_font.text( + canvas, + MAIN_MENU_X + 10, + y + 3, + &fit_text(&self.menu_font, label, 120), + if index == self.selected { + MENU_ACCENT + } else { + MENU_TEXT + }, + ); + } + + canvas.rect(20, 24, 144, 91, MENU_CARD); + canvas.rect(20, 24, 32, 1, MENU_ACCENT); + self.font + .text(canvas, 30, 31, "PILOT IDENTIFICATION", MENU_MUTED); + let portrait = self + .pilot + .legacy_progress + .as_ref() + .map_or(0, |progress| portrait_index(progress.portrait)); + self.menu_portrait(canvas, portrait, 30, 45)?; + self.menu_font.text( + canvas, + 78, + 45, + &fit_text(&self.menu_font, &self.pilot.callsign.to_uppercase(), 72), + MENU_ACCENT, + ); + for (index, line) in wrap_text(&self.font, &self.pilot.name, 72) + .iter() + .take(2) + .enumerate() + { + self.font + .text(canvas, 78, 59 + index as i32 * 9, line, MENU_TEXT); + } + self.font + .text(canvas, 78, 79, &format!("ID {:04}", self.slot), MENU_MUTED); + self.font.text( + canvas, + 30, + 92, + &fit_text( + &self.font, + &format!("CREDITS ${}", self.pilot.credits), + 120, + ), + MENU_TEXT, + ); + let mission = self + .active + .registry + .missions + .get(&self.pilot.mission) + .map(|mission| mission.name.as_str()) + .unwrap_or(&self.pilot.mission.0); + self.font.text( + canvas, + 30, + 104, + &fit_text(&self.font, &format!("NEXT {mission}"), 120), + MENU_MUTED, + ); + + canvas.rect(20, 204, 364, 18, MENU_CARD); + self.font.text( + canvas, + 30, + 207, + "UP/DOWN choose ENTER/A accept ESC/B back", + MENU_MUTED, + ); + let context = if self.message.is_empty() { + menu_context(Screen::Main, self.selected).unwrap_or("") + } else { + &self.message + }; + self.font.text( + canvas, + 30, + 216, + &fit_text(&self.font, context, 344), + if self.message.to_ascii_lowercase().contains("failed") { + 34 + } else { + MENU_TEXT + }, + ); + Ok(()) + } fn hud_asset(&mut self, canvas: &mut Canvas, asset: u32, x: i32, y: i32) -> Result<()> { self.ensure_hd(); if self.config.hd_mode { @@ -3181,8 +3325,35 @@ impl Host { Ok(()) } fn draw(&mut self) -> Result { + let canvas = self.draw_content()?; + if self.world.is_none() && self.cinematic.is_none() && self.screen != Screen::Main { + let mut frame = Canvas { + scale: canvas.scale, + image: Sprite::blank(MENU_WIDTH * canvas.scale, MENU_HEIGHT * canvas.scale), + }; + frame.blit( + &canvas.image, + MENU_OFFSET[0] * canvas.scale as i32, + MENU_OFFSET[1] * canvas.scale as i32, + ); + Ok(frame) + } else { + Ok(canvas) + } + } + fn draw_content(&mut self) -> Result { self.ensure_hd(); - let mut canvas = Canvas::new(self.config.scale); + let mut canvas = if self.screen == Screen::Main && self.cinematic.is_none() { + Canvas { + scale: self.config.scale, + image: Sprite::blank( + MENU_WIDTH * self.config.scale, + MENU_HEIGHT * self.config.scale, + ), + } + } else { + Canvas::new(self.config.scale) + }; canvas.clear(if self.world.is_some() { 0 } else { @@ -3212,6 +3383,10 @@ impl Host { return Ok(canvas); } } + if self.screen == Screen::Main { + self.main_menu(&mut canvas)?; + return Ok(canvas); + } let classic = matches!(self.screen, Screen::Main | Screen::Hangar); self.menu_panel(&mut canvas, 12, 8, 296, if classic { 31 } else { 184 }); if classic { @@ -3874,7 +4049,16 @@ fn wrap_text(font: &BitmapFont, text: &str, width: usize) -> Vec { lines } fn menu_row_at(screen: Screen, selected: usize, rows: usize, x: i32, y: i32) -> Option { - if matches!(screen, Screen::Main | Screen::Hangar) { + if screen == Screen::Main { + if !(MAIN_MENU_X..MAIN_MENU_X + 136).contains(&x) + || !(MAIN_MENU_Y..MAIN_MENU_Y + 128).contains(&y) + { + return None; + } + let selection = ((y - MAIN_MENU_Y) / 16) as usize; + return (selection < rows).then_some(selection); + } + if screen == Screen::Hangar { if !(164..300).contains(&x) || !(42..170).contains(&y) { return None; } @@ -3966,10 +4150,10 @@ fn adjust_option(config: &mut Config, selected: usize, direction: i32) -> bool { } 8 => config.difficulty = cycle(config.difficulty as i32, 4) as u32, 9 => { - (config.width, config.height) = if (config.width, config.height) == (1280, 960) { + (config.width, config.height) = if (config.width, config.height) == (1280, 720) { (1920, 1080) } else { - (1280, 960) + (1280, 720) }; } 10 => config.hd_mode = !config.hd_mode, @@ -4150,6 +4334,46 @@ fn viewport(w: u32, h: u32, aspect: bool) -> Rect { Rect::new(0, 0, w, h) } } +fn presentation_viewport(host: &Host, w: u32, h: u32) -> Rect { + if host.cinematic.is_some() { + // The original movies use non-square pixels. Always present them at + // 4:3, independently of the optional gameplay stretching setting. + viewport(w, h, true) + } else if host.world.is_none() { + let width = w.min(h * 16 / 9); + let height = h.min(w * 9 / 16); + Rect::new( + ((w - width) / 2) as i32, + ((h - height) / 2) as i32, + width, + height, + ) + } else { + viewport(w, h, host.config.aspect_correct) + } +} +fn menu_point(host: &Host, w: u32, h: u32, x: i32, y: i32) -> Option<[i32; 2]> { + let rect = presentation_viewport(host, w, h); + if !rect.contains_point((x, y)) || rect.width() == 0 || rect.height() == 0 { + return None; + } + if host.world.is_none() && host.cinematic.is_none() { + let point = [ + (x - rect.x()) * MENU_WIDTH as i32 / rect.width() as i32, + (y - rect.y()) * MENU_HEIGHT as i32 / rect.height() as i32, + ]; + Some(if host.screen == Screen::Main { + point + } else { + [point[0] - MENU_OFFSET[0], point[1] - MENU_OFFSET[1]] + }) + } else { + Some([ + (x - rect.x()) * 320 / rect.width() as i32, + (y - rect.y()) * 200 / rect.height() as i32, + ]) + } +} pub fn run( catalog: Catalog, base: Registry, @@ -4205,6 +4429,17 @@ pub fn run( .build()?, }; let creator = canvas.texture_creator(); + let sidebar_image = host.menu_art.sidebars(); + let mut sidebar_texture = creator.create_texture_static( + PixelFormatEnum::RGBA32, + sidebar_image.width(), + sidebar_image.height(), + )?; + sidebar_texture.update( + None, + sidebar_image.as_raw(), + sidebar_image.width() as usize * 4, + )?; let mut texture = creator.create_texture_streaming( PixelFormatEnum::RGBA32, (320 * host.config.scale) as u32, @@ -4336,9 +4571,9 @@ pub fn run( continue; } let (w, h) = canvas.window().size(); - let rect = viewport(w, h, host.config.aspect_correct); - let yy = (y - rect.y()) * 200 / rect.height() as i32; - let xx = (x - rect.x()) * 320 / rect.width() as i32; + let Some([xx, yy]) = menu_point(&host, w, h, x, y) else { + continue; + }; if host.screen == Screen::PilotForm && (252..292).contains(&xx) && (46..86).contains(&yy) @@ -4625,12 +4860,40 @@ pub fn run( } let image = host.draw()?; let rgba = host.present_rgba(&image); + if (texture.query().width, texture.query().height) + != (image.image.width as u32, image.image.height as u32) + { + texture = creator.create_texture_streaming( + PixelFormatEnum::RGBA32, + image.image.width as u32, + image.image.height as u32, + )?; + } texture.update(None, &rgba, image.image.width * 4)?; let (w, h) = canvas.output_size().map_err(|e| anyhow!(e))?; canvas.set_draw_color(sdl2::pixels::Color::BLACK); canvas.clear(); + let destination = presentation_viewport(&host, w, h); + if host.world.is_some() + && host.cinematic.is_none() + && (destination.width() < w || destination.height() < h) + { + canvas + .copy(&sidebar_texture, None, None) + .map_err(|e| anyhow!(e))?; + canvas.set_draw_color(sdl2::pixels::Color::RGB(65, 54, 39)); + if destination.x() > 0 { + canvas + .draw_line((destination.x() - 1, 0), (destination.x() - 1, h as i32)) + .map_err(|e| anyhow!(e))?; + let right = destination.x() + destination.width() as i32; + canvas + .draw_line((right, 0), (right, h as i32)) + .map_err(|e| anyhow!(e))?; + } + } canvas - .copy(&texture, None, viewport(w, h, host.config.aspect_correct)) + .copy(&texture, None, destination) .map_err(|e| anyhow!(e))?; if frames.is_some_and(|n| count + 1 >= n) { if let Some(path) = &screenshot { @@ -5051,6 +5314,24 @@ mod tests { .collect() } #[test] + fn cinematics_keep_four_three_when_gameplay_stretching_is_enabled() { + let (_directory, mut host) = opening_card_fixture(); + host.config.aspect_correct = false; + for (width, height) in [(1280, 720), (1280, 800), (1920, 1080)] { + let rect = presentation_viewport(&host, width, height); + assert!((rect.width() * 3).abs_diff(rect.height() * 4) <= 3); + } + assert_eq!( + presentation_viewport(&host, 1920, 1080), + Rect::new(240, 0, 1440, 1080) + ); + host.cinematic = None; + assert_eq!( + presentation_viewport(&host, 1920, 1080), + Rect::new(0, 0, 1920, 1080) + ); + } + #[test] fn supplied_hd_opening_cards_use_unfaded_source_palettes_without_advancing_movies_or_world() { let (_dir, mut host) = opening_card_fixture(); assert!(!host.config.hd_mode); @@ -6136,8 +6417,8 @@ mod tests { Screen::Main, 7, host.rows().len(), - 200, - 47 + row as i32 * 16 + MAIN_MENU_X + 20, + MAIN_MENU_Y + 5 + row as i32 * 16 ), Some(row) ); @@ -6676,7 +6957,10 @@ mod tests { assert_eq!(menu_row_at(Screen::Shop, 1, 20, 170, 60), None); assert_eq!(menu_row_at(Screen::Shop, 1, 20, 159, 60), Some(1)); assert_eq!(menu_row_at(Screen::Shop, 1, 20, 40, 154), None); - assert_eq!(menu_row_at(Screen::Main, 1, 8, 200, 60), Some(1)); + assert_eq!( + menu_row_at(Screen::Main, 1, 8, MAIN_MENU_X + 20, MAIN_MENU_Y + 18), + Some(1) + ); } #[test] fn mission_preview_is_not_an_action_hitbox() { @@ -7143,8 +7427,11 @@ mod tests { host.selected = selected; let canvas = host.draw().unwrap(); assert_eq!( - canvas.image.pixels[(43 + selected * 16) * 4 * canvas.image.width + 164 * 4], - 48 + canvas.image.pixels[(MAIN_MENU_Y as usize + 1 + selected * 16) + * 4 + * canvas.image.width + + MAIN_MENU_X as usize * 4], + crate::menu_art::MENU_ACCENT ); capture(&mut host, name); } @@ -7748,6 +8035,57 @@ mod tests { assert_eq!(viewport(1920, 1080, false), Rect::new(0, 0, 1920, 1080)); } #[test] + fn widescreen_menus_map_every_action_and_keep_gameplay_in_four_three() { + let (_directory, mut host) = ui_fixture(); + for (width, height) in [(1280, 720), (1280, 800), (1920, 1080)] { + let rect = presentation_viewport(&host, width, height); + assert_eq!(rect.width() * 9, rect.height() * 16); + for row in 0..8 { + let logical = [MAIN_MENU_X + 20, MAIN_MENU_Y + row * 16 + 5]; + let point = menu_point( + &host, + width, + height, + rect.x() + logical[0] * rect.width() as i32 / MENU_WIDTH as i32, + rect.y() + logical[1] * rect.height() as i32 / MENU_HEIGHT as i32, + ) + .unwrap(); + assert_eq!( + menu_row_at(Screen::Main, 7, 8, point[0], point[1]), + Some(row as usize) + ); + } + assert!(menu_point(&host, width, height, -1, -1).is_none()); + } + host.go(Screen::Hangar); + let point = menu_point( + &host, + 1920, + 1080, + (MENU_OFFSET[0] + 200) * 1920 / MENU_WIDTH as i32, + (MENU_OFFSET[1] + 111) * 1080 / MENU_HEIGHT as i32, + ) + .unwrap(); + assert_eq!( + menu_row_at(Screen::Hangar, 0, 7, point[0], point[1]), + Some(4) + ); + let menu = host.draw().unwrap(); + assert_eq!( + (menu.image.width, menu.image.height), + ( + MENU_WIDTH * host.config.scale, + MENU_HEIGHT * host.config.scale + ) + ); + host.world = + Some(World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap()); + assert_eq!( + presentation_viewport(&host, 1920, 1080), + Rect::new(240, 0, 1440, 1080) + ); + } + #[test] fn palette_effects_do_not_consume_simulation_rng() { let (registry, pilot) = fixture(); let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); diff --git a/RustRaptor/src/main.rs b/RustRaptor/src/main.rs index dca288c7..546d49e6 100644 --- a/RustRaptor/src/main.rs +++ b/RustRaptor/src/main.rs @@ -6,7 +6,20 @@ use raptor_replica::{ persistence::{Paths, Pilot}, simulation::{Input, World}, }; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +fn package_at(executable: &Path, working: &Path, development: &Path) -> PathBuf { + let directory = executable.parent().unwrap_or(working); + if directory.file_name().is_some_and(|name| name == "bin") { + return directory.parent().unwrap_or(directory).to_owned(); + } + for candidate in [directory, working] { + if candidate.join("data/INDEX").is_file() { + return candidate.to_owned(); + } + } + development.to_owned() +} fn main() { if let Err(e) = run() { eprintln!("{e:#}"); @@ -36,15 +49,20 @@ fn run() -> Result<()> { println!("Wrote API v1 schemas to {path}"); return Ok(()); } - let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let package = package_at( + &std::env::current_exe()?, + &std::env::current_dir()?, + Path::new(env!("CARGO_MANIFEST_DIR")), + ); let data = arg("--data").map(PathBuf::from).unwrap_or_else(|| { - if package.join("data/INDEX").is_file() { + if package.join("data/INDEX").is_file() || package.join("bin/raptor-rust").is_file() { package.join("data") } else { package.join("../data") } }); - let catalog = Catalog::open(data)?; + let catalog = Catalog::open(&data) + .with_context(|| format!("Could not open game data at {}", data.display()))?; if args.iter().any(|a| a == "--validate-hd") { let hd_root = catalog.root.join("hd"); anyhow::ensure!( @@ -237,3 +255,33 @@ fn run() -> Result<()> { #[cfg(feature = "desktop")] Ok(()) } + +#[cfg(test)] +mod package_tests { + use super::*; + #[test] + fn relocated_bin_uses_its_own_package_even_when_catalog_is_missing() { + assert_eq!( + package_at( + Path::new("/copied/RustRaptor/bin/raptor-rust"), + Path::new("/elsewhere"), + Path::new("/old/build") + ), + Path::new("/copied/RustRaptor") + ); + } + #[test] + fn direct_executable_prefers_its_sibling_data_over_working_directory() { + let folder = tempfile::tempdir().unwrap(); + std::fs::create_dir(folder.path().join("data")).unwrap(); + std::fs::write(folder.path().join("data/INDEX"), "").unwrap(); + assert_eq!( + package_at( + &folder.path().join("raptor-rust"), + Path::new("/elsewhere"), + Path::new("/old/build") + ), + folder.path() + ); + } +} diff --git a/RustRaptor/src/menu_art.rs b/RustRaptor/src/menu_art.rs index e0b6a089..8e986a28 100644 --- a/RustRaptor/src/menu_art.rs +++ b/RustRaptor/src/menu_art.rs @@ -1,16 +1,19 @@ //! Full-color menu illustration; indexed gameplay and movie palettes stay separate. use crate::{assets::TRANSPARENT, render::Canvas}; -use image::{ - RgbaImage, - imageops::{FilterType, resize}, -}; +use image::{DynamicImage, RgbaImage, imageops::FilterType}; use std::collections::BTreeMap; pub const MENU_PANEL: u16 = 257; +pub const MENU_CARD: u16 = 258; +pub const MENU_FOCUS: u16 = 259; +pub const MENU_TEXT: u16 = 260; +pub const MENU_MUTED: u16 = 261; +pub const MENU_ACCENT: u16 = 262; +pub const MENU_RULE: u16 = 263; pub struct MenuArt { original: RgbaImage, - resized: BTreeMap, + resized: BTreeMap<(usize, usize, usize), RgbaImage>, } impl MenuArt { pub fn bundled() -> Self { @@ -28,21 +31,38 @@ impl MenuArt { palette: &[[u8; 3]; 256], scanlines: bool, ) -> Vec { - let background = self.resized.entry(canvas.scale).or_insert_with(|| { - resize( - &self.original, - canvas.image.width as u32, - canvas.image.height as u32, - FilterType::Lanczos3, - ) - }); + let background = self + .resized + .entry((canvas.scale, canvas.image.width, canvas.image.height)) + .or_insert_with(|| { + DynamicImage::ImageRgba8(self.original.clone()) + .resize_to_fill( + canvas.image.width as u32, + canvas.image.height as u32, + FilterType::Lanczos3, + ) + .into_rgba8() + }); let mut rgba = background.as_raw().clone(); for (i, index) in canvas.image.pixels.iter().enumerate() { - let color = if *index == MENU_PANEL { + let color = if matches!(*index, MENU_PANEL | MENU_CARD | MENU_FOCUS) { + let (tint, opacity) = match *index { + MENU_CARD => ([10u16, 17, 23], 72), + MENU_FOCUS => ([49u16, 38, 27], 82), + _ => ([12u16, 18, 29], 85), + }; [0, 1, 2].map(|channel| { - (([12u16, 18, 29][channel] * 85 + rgba[i * 4 + channel] as u16 * 15) / 100) - as u8 + ((tint[channel] * opacity + rgba[i * 4 + channel] as u16 * (100 - opacity)) + / 100) as u8 }) + } else if *index == MENU_TEXT { + [221, 225, 224] + } else if *index == MENU_MUTED { + [147, 163, 171] + } else if *index == MENU_ACCENT { + [237, 180, 105] + } else if *index == MENU_RULE { + [65, 79, 85] } else if *index == TRANSPARENT { [rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]] } else { @@ -57,6 +77,18 @@ impl MenuArt { } rgba } + pub fn sidebars(&self) -> RgbaImage { + let mut image = DynamicImage::ImageRgba8(self.original.clone()) + .resize_to_fill(800, 450, FilterType::Lanczos3) + .into_rgba8(); + for pixel in image.pixels_mut() { + for channel in &mut pixel.0[..3] { + *channel = (*channel as u16 * 23 / 100) as u8; + } + pixel[3] = 255; + } + image + } } #[cfg(test)] diff --git a/RustRaptor/src/persistence.rs b/RustRaptor/src/persistence.rs index 77df3ddc..8f8efdfe 100644 --- a/RustRaptor/src/persistence.rs +++ b/RustRaptor/src/persistence.rs @@ -69,7 +69,7 @@ impl Default for Config { borderless: false, aspect_correct: true, width: 1280, - height: 960, + height: 720, music_volume: 85, fx_volume: 85, fx_channels: 2, diff --git a/RustRaptor/src/render.rs b/RustRaptor/src/render.rs index 5819ffac..956f22da 100644 --- a/RustRaptor/src/render.rs +++ b/RustRaptor/src/render.rs @@ -471,7 +471,11 @@ impl BitmapFont { let mut glyph = g.clone(); for p in &mut glyph.pixels { if *p != TRANSPARENT { - *p = (*p + color.saturating_sub(1)).min(255); + *p = if color > TRANSPARENT { + color + } else { + (*p + color.saturating_sub(1)).min(255) + }; } } canvas.blit( @@ -481,7 +485,7 @@ impl BitmapFont { ); } cursor += self.widths[ch as usize] as i32 + 1; - if cursor > 314 { + if cursor > (canvas.image.width / canvas.scale) as i32 - 6 { break; } } diff --git a/RustRaptor/tools/assemble_runtime.py b/RustRaptor/tools/assemble_runtime.py new file mode 100644 index 00000000..fbd52a35 --- /dev/null +++ b/RustRaptor/tools/assemble_runtime.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Refresh the self-contained Linux runtime and optionally export a clean zip.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import zipfile + + +def digest(path): + with path.open("rb") as handle: + return hashlib.file_digest(handle, "sha256").hexdigest() + + +def checked_file(root, relative): + path = Path(relative) + if path.is_absolute() or ".." in path.parts: + raise ValueError(f"unsafe runtime path: {relative}") + resolved = (root / path).resolve(strict=True) + resolved.relative_to(root.resolve()) + if not resolved.is_file(): + raise ValueError(f"not a runtime file: {relative}") + return resolved + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-data", type=Path) + parser.add_argument("--skip-build", action="store_true") + parser.add_argument("--zip", type=Path, help="export runtime, source, docs and mods; exclude build caches") + args = parser.parse_args() + package = Path(__file__).resolve().parents[1] + source = (args.source_data or package.parent / "data").resolve() + environment = os.environ.copy() + local_lib = Path.home() / ".local/lib" + environment["LD_LIBRARY_PATH"] = str(local_lib) + ":" + environment.get("LD_LIBRARY_PATH", "") + if not args.skip_build: + subprocess.run(["cargo", "build", "--locked", "--release", "--bin", "raptor-rust", "--features", "desktop"], + cwd=package, env=environment, check=True) + executable = package / "target/release/raptor-rust" + if not executable.is_file(): + raise ValueError("release executable is missing; run without --skip-build") + + files = {"INDEX"} + entries = (source / "INDEX").read_text().splitlines() + for row in entries: + if not row.strip(): + continue + _, length, name = row.split() + original = checked_file(source, name) + if original.stat().st_size != int(length): + raise ValueError(f"catalog size mismatch: {name}") + files.add(name) + hd_path = source / "hd/hd.json" + hd_bytes = hd_path.read_bytes() if hd_path.is_file() else None + hd_frames = 0 + if hd_bytes: + hd = json.loads(hd_bytes) + files.add("hd/hd.json") + for sequence in hd["sequences"]: + for frame in sequence["frames"]: + checked_file(source / "hd", frame["file"]) + files.add("hd/" + frame["file"]) + hd_frames += 1 + + staging = Path(tempfile.mkdtemp(prefix=".runtime-data-", dir=package)) + try: + for name in sorted(files): + destination = staging / name + destination.parent.mkdir(parents=True, exist_ok=True) + if name == "hd/hd.json": + destination.write_bytes(hd_bytes) + else: + shutil.copy2(checked_file(source, name), destination) + previous = package / ".runtime-data-previous" + if previous.exists(): + raise ValueError("previous interrupted package exists; preserve/recover .runtime-data-previous before retrying") + data = package / "data" + if data.exists(): + data.rename(previous) + try: + staging.rename(data) + except BaseException: + if previous.exists(): + previous.rename(data) + raise + if previous.exists(): + shutil.rmtree(previous) + finally: + if staging.exists(): + shutil.rmtree(staging) + + (package / "bin").mkdir(exist_ok=True) + shutil.copy2(executable, package / "bin/raptor-rust") + dependencies = subprocess.run(["ldd", str(executable)], env=environment, + capture_output=True, text=True, check=False).stdout + if "not found" in dependencies: + raise ValueError("unresolved release dependency:\n" + dependencies) + sdl_libraries = re.findall(r"libSDL2[^ ]* => ([^ ]+)", dependencies) + if not sdl_libraries: + raise ValueError("release binary has no SDL2 dependency; rebuild with the desktop feature") + for library in sdl_libraries: + destination = package / "lib" + destination.mkdir(exist_ok=True) + shutil.copy2(Path(library).resolve(), destination / "libSDL2-2.0.so.0") + copyright_file = Path("/usr/share/doc/libsdl2-2.0-0/copyright") + if copyright_file.is_file(): + shutil.copy2(copyright_file, destination / "SDL2-COPYRIGHT.txt") + report = {"format_version": 1, "platform": "Linux x86_64", "catalog_entries": len([row for row in entries if row.strip()]), + "approved_hd_frames": hd_frames, "binary_sha256": digest(package / "bin/raptor-rust"), + "data_files": len(files), "data_bytes": sum((package / "data" / name).stat().st_size for name in files), + "files": {"data/" + name: digest(package / "data" / name) for name in sorted(files)}, + "system_dependencies": dependencies.splitlines()} + (package / "runtime-manifest.json").write_text(json.dumps(report, indent=2) + "\n") + subprocess.run([str(package / "run.sh"), "--verify-assets"], cwd="/", check=True) + if args.zip: + archive = args.zip.resolve() + archive.parent.mkdir(parents=True, exist_ok=True) + skipped = {"target", "dist", ".reference", "__pycache__", ".git"} + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as output: + for path in sorted(package.rglob("*")): + relative = path.relative_to(package) + if path.is_file() and not skipped.intersection(relative.parts) and path.resolve() != archive: + if any(part.startswith(".runtime-data-") for part in relative.parts): + continue + output.write(path, str(Path("RustRaptor") / relative)) + print(json.dumps({key: value for key, value in report.items() if key not in {"files", "system_dependencies"}}, indent=2)) + + +if __name__ == "__main__": + main()