From e17bc1003ebbd73e0a1cb3eaec475ef00a44bc35 Mon Sep 17 00:00:00 2001 From: raptorreplica Date: Sat, 10 Oct 2026 13:47:41 +1100 Subject: [PATCH] Package RustRaptor and refine controls, menus, saves, sound and mod selection --- .gitignore | 6 +- PLAN.md | 30 - README.md | 24 +- Cargo.lock => RustRaptor/Cargo.lock | 0 Cargo.toml => RustRaptor/Cargo.toml | 0 RustRaptor/LICENSE | 339 ++ RustRaptor/PLAN.md | 30 + RustRaptor/README.md | 77 + build.rs => RustRaptor/build.rs | 0 .../docs}/campaign-validation.md | 32 +- .../docs}/first-mission-validation.md | 6 +- {docs => RustRaptor/docs}/hd-art.md | 16 +- {docs => RustRaptor/docs}/iron-coast.md | 37 +- {docs => RustRaptor/docs}/modding-api.md | 29 +- RustRaptor/docs/performance.md | 41 + RustRaptor/docs/rust-rewrite.md | 101 + RustRaptor/docs/sound-validation.md | 52 + .../docs}/transition-validation.md | 8 +- .../docs}/visual-validation.md | 6 +- RustRaptor/docs/weapon-balance.md | 107 + .../mods/aurora/README.md | 6 +- .../mods/aurora/content.json | 0 {examples => RustRaptor}/mods/aurora/mod.toml | 0 .../mods/aurora/scripts/cell.rhai | 0 .../mods/aurora/scripts/drone.rhai | 0 .../mods/aurora/scripts/mission.rhai | 0 .../mods/aurora/scripts/weapon.rhai | 0 .../mods/ion_lance/ART_PROVENANCE.md | 0 .../mods/ion_lance/README.md | 4 +- .../mods/ion_lance/art-provenance.json | 0 .../art/equipment-generated-source.png | Bin .../mods/ion_lance/art/equipment-hd.png | Bin .../mods/ion_lance/art/equipment-sd.png | Bin .../art/projectile-generated-source.png | Bin .../mods/ion_lance/art/projectile-hd.png | Bin .../mods/ion_lance/art/projectile-sd.png | Bin .../mods/ion_lance/audio/fire.wav | Bin .../mods/ion_lance/audio/impact.wav | Bin .../mods/ion_lance/content.json | 5 +- .../mods/ion_lance/generate_audio.py | 0 .../mods/ion_lance/mod.toml | 0 .../mods/ion_lance/prepare_art.rs | 0 .../mods/ion_lance/scripts/equipment.rhai | 0 .../mods/ion_lance/scripts/weapon.rhai | 5 +- .../mods/iron_coast_boss/README.md | 2 +- .../mods/iron_coast_boss/art-qa.json | 0 .../mods/iron_coast_boss/art/preview.png | Bin .../art/projectile-generated-v1.png | Bin .../iron_coast_boss/art/projectile-hd.png | Bin .../iron_coast_boss/art/projectile-sd.png | Bin .../art/unit-charge-generated-v1.png | Bin .../iron_coast_boss/art/unit-generated-v1.png | Bin .../mods/iron_coast_boss/art/unit-hd.png | Bin .../mods/iron_coast_boss/art/unit-sd.png | Bin .../mods/iron_coast_boss/audio-recipe.json | 0 .../mods/iron_coast_boss/audio/death.wav | Bin .../mods/iron_coast_boss/audio/shot.wav | Bin .../mods/iron_coast_boss/build_assets.py | 4 +- .../mods/iron_coast_boss/content.json | 0 .../mods/iron_coast_boss/mod.toml | 0 .../prompts/projectile-hd-v1.txt | 0 .../prompts/unit-charge-v1.txt | 0 .../iron_coast_boss/prompts/unit-hd-v1.txt | 0 .../mods/iron_coast_boss/provenance.json | 0 .../mods/iron_coast_boss/scripts/ai.rhai | 0 .../mods/iron_coast_mission_1/README.md | 13 +- .../art/briefing-source-hd.png | Bin .../iron_coast_mission_1/art/briefing_hd.png | Bin .../iron_coast_mission_1/art/briefing_sd.png | Bin .../art/depot-source-hd.png | Bin .../iron_coast_mission_1/art/depot_hd.png | Bin .../iron_coast_mission_1/art/depot_sd.png | Bin .../iron_coast_mission_1/art/repair_hd.png | Bin .../iron_coast_mission_1/art/repair_sd.png | Bin .../art/terrain-preview-hd.png | Bin .../art/terrain-preview-sd.png | Bin .../art/terrain-source-hd.png | Bin .../iron_coast_mission_1/art/tile_000_hd.png | Bin .../iron_coast_mission_1/art/tile_000_sd.png | Bin .../iron_coast_mission_1/art/tile_001_hd.png | Bin .../iron_coast_mission_1/art/tile_001_sd.png | Bin .../iron_coast_mission_1/art/tile_002_hd.png | Bin .../iron_coast_mission_1/art/tile_002_sd.png | Bin .../iron_coast_mission_1/art/tile_003_hd.png | Bin .../iron_coast_mission_1/art/tile_003_sd.png | Bin .../iron_coast_mission_1/art/tile_004_hd.png | Bin .../iron_coast_mission_1/art/tile_004_sd.png | Bin .../iron_coast_mission_1/art/tile_005_hd.png | Bin .../iron_coast_mission_1/art/tile_005_sd.png | Bin .../iron_coast_mission_1/art/tile_006_hd.png | Bin .../iron_coast_mission_1/art/tile_006_sd.png | Bin .../iron_coast_mission_1/art/tile_007_hd.png | Bin .../iron_coast_mission_1/art/tile_007_sd.png | Bin .../iron_coast_mission_1/art/tile_008_hd.png | Bin .../iron_coast_mission_1/art/tile_008_sd.png | Bin .../iron_coast_mission_1/art/tile_009_hd.png | Bin .../iron_coast_mission_1/art/tile_009_sd.png | Bin .../iron_coast_mission_1/art/tile_010_hd.png | Bin .../iron_coast_mission_1/art/tile_010_sd.png | Bin .../iron_coast_mission_1/art/tile_011_hd.png | Bin .../iron_coast_mission_1/art/tile_011_sd.png | Bin .../iron_coast_mission_1/art/tile_012_hd.png | Bin .../iron_coast_mission_1/art/tile_012_sd.png | Bin .../iron_coast_mission_1/art/tile_013_hd.png | Bin .../iron_coast_mission_1/art/tile_013_sd.png | Bin .../iron_coast_mission_1/art/tile_014_hd.png | Bin .../iron_coast_mission_1/art/tile_014_sd.png | Bin .../iron_coast_mission_1/art/tile_015_hd.png | Bin .../iron_coast_mission_1/art/tile_015_sd.png | Bin .../iron_coast_mission_1/art/tile_016_hd.png | Bin .../iron_coast_mission_1/art/tile_016_sd.png | Bin .../iron_coast_mission_1/art/tile_017_hd.png | Bin .../iron_coast_mission_1/art/tile_017_sd.png | Bin .../iron_coast_mission_1/art/tile_018_hd.png | Bin .../iron_coast_mission_1/art/tile_018_sd.png | Bin .../iron_coast_mission_1/art/tile_019_hd.png | Bin .../iron_coast_mission_1/art/tile_019_sd.png | Bin .../iron_coast_mission_1/art/tile_020_hd.png | Bin .../iron_coast_mission_1/art/tile_020_sd.png | Bin .../iron_coast_mission_1/art/tile_021_hd.png | Bin .../iron_coast_mission_1/art/tile_021_sd.png | Bin .../iron_coast_mission_1/art/tile_022_hd.png | Bin .../iron_coast_mission_1/art/tile_022_sd.png | Bin .../iron_coast_mission_1/art/tile_023_hd.png | Bin .../iron_coast_mission_1/art/tile_023_sd.png | Bin .../iron_coast_mission_1/art/tile_024_hd.png | Bin .../iron_coast_mission_1/art/tile_024_sd.png | Bin .../iron_coast_mission_1/art/tile_025_hd.png | Bin .../iron_coast_mission_1/art/tile_025_sd.png | Bin .../iron_coast_mission_1/art/tile_026_hd.png | Bin .../iron_coast_mission_1/art/tile_026_sd.png | Bin .../iron_coast_mission_1/art/tile_027_hd.png | Bin .../iron_coast_mission_1/art/tile_027_sd.png | Bin .../iron_coast_mission_1/art/tile_028_hd.png | Bin .../iron_coast_mission_1/art/tile_028_sd.png | Bin .../iron_coast_mission_1/art/tile_029_hd.png | Bin .../iron_coast_mission_1/art/tile_029_sd.png | Bin .../iron_coast_mission_1/art/tile_030_hd.png | Bin .../iron_coast_mission_1/art/tile_030_sd.png | Bin .../iron_coast_mission_1/art/tile_031_hd.png | Bin .../iron_coast_mission_1/art/tile_031_sd.png | Bin .../iron_coast_mission_1/art/tile_032_hd.png | Bin .../iron_coast_mission_1/art/tile_032_sd.png | Bin .../iron_coast_mission_1/art/tile_033_hd.png | Bin .../iron_coast_mission_1/art/tile_033_sd.png | Bin .../iron_coast_mission_1/art/tile_034_hd.png | Bin .../iron_coast_mission_1/art/tile_034_sd.png | Bin .../iron_coast_mission_1/art/tile_035_hd.png | Bin .../iron_coast_mission_1/art/tile_035_sd.png | Bin .../iron_coast_mission_1/art/tile_036_hd.png | Bin .../iron_coast_mission_1/art/tile_036_sd.png | Bin .../iron_coast_mission_1/art/tile_037_hd.png | Bin .../iron_coast_mission_1/art/tile_037_sd.png | Bin .../iron_coast_mission_1/art/tile_038_hd.png | Bin .../iron_coast_mission_1/art/tile_038_sd.png | Bin .../iron_coast_mission_1/art/tile_039_hd.png | Bin .../iron_coast_mission_1/art/tile_039_sd.png | Bin .../iron_coast_mission_1/art/tile_040_hd.png | Bin .../iron_coast_mission_1/art/tile_040_sd.png | Bin .../iron_coast_mission_1/art/tile_041_hd.png | Bin .../iron_coast_mission_1/art/tile_041_sd.png | Bin .../iron_coast_mission_1/art/tile_042_hd.png | Bin .../iron_coast_mission_1/art/tile_042_sd.png | Bin .../iron_coast_mission_1/art/tile_043_hd.png | Bin .../iron_coast_mission_1/art/tile_043_sd.png | Bin .../iron_coast_mission_1/art/tile_044_hd.png | Bin .../iron_coast_mission_1/art/tile_044_sd.png | Bin .../iron_coast_mission_1/art/tile_045_hd.png | Bin .../iron_coast_mission_1/art/tile_045_sd.png | Bin .../iron_coast_mission_1/art/tile_046_hd.png | Bin .../iron_coast_mission_1/art/tile_046_sd.png | Bin .../iron_coast_mission_1/art/tile_047_hd.png | Bin .../iron_coast_mission_1/art/tile_047_sd.png | Bin .../iron_coast_mission_1/art/tile_048_hd.png | Bin .../iron_coast_mission_1/art/tile_048_sd.png | Bin .../iron_coast_mission_1/art/tile_049_hd.png | Bin .../iron_coast_mission_1/art/tile_049_sd.png | Bin .../iron_coast_mission_1/art/tile_050_hd.png | Bin .../iron_coast_mission_1/art/tile_050_sd.png | Bin .../iron_coast_mission_1/art/tile_051_hd.png | Bin .../iron_coast_mission_1/art/tile_051_sd.png | Bin .../iron_coast_mission_1/art/tile_052_hd.png | Bin .../iron_coast_mission_1/art/tile_052_sd.png | Bin .../iron_coast_mission_1/art/tile_053_hd.png | Bin .../iron_coast_mission_1/art/tile_053_sd.png | Bin .../iron_coast_mission_1/art/tile_054_hd.png | Bin .../iron_coast_mission_1/art/tile_054_sd.png | Bin .../iron_coast_mission_1/art/tile_055_hd.png | Bin .../iron_coast_mission_1/art/tile_055_sd.png | Bin .../iron_coast_mission_1/art/tile_056_hd.png | Bin .../iron_coast_mission_1/art/tile_056_sd.png | Bin .../iron_coast_mission_1/art/tile_057_hd.png | Bin .../iron_coast_mission_1/art/tile_057_sd.png | Bin .../iron_coast_mission_1/art/tile_058_hd.png | Bin .../iron_coast_mission_1/art/tile_058_sd.png | Bin .../iron_coast_mission_1/art/tile_059_hd.png | Bin .../iron_coast_mission_1/art/tile_059_sd.png | Bin .../iron_coast_mission_1/art/tile_060_hd.png | Bin .../iron_coast_mission_1/art/tile_060_sd.png | Bin .../iron_coast_mission_1/art/tile_061_hd.png | Bin .../iron_coast_mission_1/art/tile_061_sd.png | Bin .../iron_coast_mission_1/art/tile_062_hd.png | Bin .../iron_coast_mission_1/art/tile_062_sd.png | Bin .../iron_coast_mission_1/art/tile_063_hd.png | Bin .../iron_coast_mission_1/art/tile_063_sd.png | Bin .../iron_coast_mission_1/art/tile_064_hd.png | Bin .../iron_coast_mission_1/art/tile_064_sd.png | Bin .../iron_coast_mission_1/art/tile_065_hd.png | Bin .../iron_coast_mission_1/art/tile_065_sd.png | Bin .../iron_coast_mission_1/art/tile_066_hd.png | Bin .../iron_coast_mission_1/art/tile_066_sd.png | Bin .../iron_coast_mission_1/art/tile_067_hd.png | Bin .../iron_coast_mission_1/art/tile_067_sd.png | Bin .../iron_coast_mission_1/art/tile_068_hd.png | Bin .../iron_coast_mission_1/art/tile_068_sd.png | Bin .../iron_coast_mission_1/art/tile_069_hd.png | Bin .../iron_coast_mission_1/art/tile_069_sd.png | Bin .../iron_coast_mission_1/art/tile_070_hd.png | Bin .../iron_coast_mission_1/art/tile_070_sd.png | Bin .../iron_coast_mission_1/art/tile_071_hd.png | Bin .../iron_coast_mission_1/art/tile_071_sd.png | Bin .../iron_coast_mission_1/art/tile_072_hd.png | Bin .../iron_coast_mission_1/art/tile_072_sd.png | Bin .../iron_coast_mission_1/art/tile_073_hd.png | Bin .../iron_coast_mission_1/art/tile_073_sd.png | Bin .../iron_coast_mission_1/art/tile_074_hd.png | Bin .../iron_coast_mission_1/art/tile_074_sd.png | Bin .../iron_coast_mission_1/art/tile_075_hd.png | Bin .../iron_coast_mission_1/art/tile_075_sd.png | Bin .../iron_coast_mission_1/art/tile_076_hd.png | Bin .../iron_coast_mission_1/art/tile_076_sd.png | Bin .../iron_coast_mission_1/art/tile_077_hd.png | Bin .../iron_coast_mission_1/art/tile_077_sd.png | Bin .../iron_coast_mission_1/art/tile_078_hd.png | Bin .../iron_coast_mission_1/art/tile_078_sd.png | Bin .../iron_coast_mission_1/art/tile_079_hd.png | Bin .../iron_coast_mission_1/art/tile_079_sd.png | Bin .../iron_coast_mission_1/art/tile_080_hd.png | Bin .../iron_coast_mission_1/art/tile_080_sd.png | Bin .../iron_coast_mission_1/art/tile_081_hd.png | Bin .../iron_coast_mission_1/art/tile_081_sd.png | Bin .../iron_coast_mission_1/art/tile_082_hd.png | Bin .../iron_coast_mission_1/art/tile_082_sd.png | Bin .../iron_coast_mission_1/art/tile_083_hd.png | Bin .../iron_coast_mission_1/art/tile_083_sd.png | Bin .../iron_coast_mission_1/art/tile_084_hd.png | Bin .../iron_coast_mission_1/art/tile_084_sd.png | Bin .../iron_coast_mission_1/art/tile_085_hd.png | Bin .../iron_coast_mission_1/art/tile_085_sd.png | Bin .../iron_coast_mission_1/art/tile_086_hd.png | Bin .../iron_coast_mission_1/art/tile_086_sd.png | Bin .../iron_coast_mission_1/art/tile_087_hd.png | Bin .../iron_coast_mission_1/art/tile_087_sd.png | Bin .../iron_coast_mission_1/art/tile_088_hd.png | Bin .../iron_coast_mission_1/art/tile_088_sd.png | Bin .../iron_coast_mission_1/art/tile_089_hd.png | Bin .../iron_coast_mission_1/art/tile_089_sd.png | Bin .../iron_coast_mission_1/art/tile_090_hd.png | Bin .../iron_coast_mission_1/art/tile_090_sd.png | Bin .../iron_coast_mission_1/art/tile_091_hd.png | Bin .../iron_coast_mission_1/art/tile_091_sd.png | Bin .../iron_coast_mission_1/art/tile_092_hd.png | Bin .../iron_coast_mission_1/art/tile_092_sd.png | Bin .../iron_coast_mission_1/art/tile_093_hd.png | Bin .../iron_coast_mission_1/art/tile_093_sd.png | Bin .../iron_coast_mission_1/art/tile_094_hd.png | Bin .../iron_coast_mission_1/art/tile_094_sd.png | Bin .../iron_coast_mission_1/art/tile_095_hd.png | Bin .../iron_coast_mission_1/art/tile_095_sd.png | Bin .../iron_coast_mission_1/art/tile_096_hd.png | Bin .../iron_coast_mission_1/art/tile_096_sd.png | Bin .../iron_coast_mission_1/art/tile_097_hd.png | Bin .../iron_coast_mission_1/art/tile_097_sd.png | Bin .../iron_coast_mission_1/art/tile_098_hd.png | Bin .../iron_coast_mission_1/art/tile_098_sd.png | Bin .../iron_coast_mission_1/art/tile_099_hd.png | Bin .../iron_coast_mission_1/art/tile_099_sd.png | Bin .../iron_coast_mission_1/art/tile_100_hd.png | Bin .../iron_coast_mission_1/art/tile_100_sd.png | Bin .../iron_coast_mission_1/art/tile_101_hd.png | Bin .../iron_coast_mission_1/art/tile_101_sd.png | Bin .../iron_coast_mission_1/art/tile_102_hd.png | Bin .../iron_coast_mission_1/art/tile_102_sd.png | Bin .../iron_coast_mission_1/art/tile_103_hd.png | Bin .../iron_coast_mission_1/art/tile_103_sd.png | Bin .../iron_coast_mission_1/art/tile_104_hd.png | Bin .../iron_coast_mission_1/art/tile_104_sd.png | Bin .../iron_coast_mission_1/art/tile_105_hd.png | Bin .../iron_coast_mission_1/art/tile_105_sd.png | Bin .../iron_coast_mission_1/art/tile_106_hd.png | Bin .../iron_coast_mission_1/art/tile_106_sd.png | Bin .../iron_coast_mission_1/art/tile_107_hd.png | Bin .../iron_coast_mission_1/art/tile_107_sd.png | Bin .../iron_coast_mission_1/art/tile_108_hd.png | Bin .../iron_coast_mission_1/art/tile_108_sd.png | Bin .../iron_coast_mission_1/art/tile_109_hd.png | Bin .../iron_coast_mission_1/art/tile_109_sd.png | Bin .../iron_coast_mission_1/art/tile_110_hd.png | Bin .../iron_coast_mission_1/art/tile_110_sd.png | Bin .../iron_coast_mission_1/art/tile_111_hd.png | Bin .../iron_coast_mission_1/art/tile_111_sd.png | Bin .../iron_coast_mission_1/art/tile_112_hd.png | Bin .../iron_coast_mission_1/art/tile_112_sd.png | Bin .../iron_coast_mission_1/art/tile_113_hd.png | Bin .../iron_coast_mission_1/art/tile_113_sd.png | Bin .../iron_coast_mission_1/art/tile_114_hd.png | Bin .../iron_coast_mission_1/art/tile_114_sd.png | Bin .../iron_coast_mission_1/art/tile_115_hd.png | Bin .../iron_coast_mission_1/art/tile_115_sd.png | Bin .../iron_coast_mission_1/art/tile_116_hd.png | Bin .../iron_coast_mission_1/art/tile_116_sd.png | Bin .../iron_coast_mission_1/art/tile_117_hd.png | Bin .../iron_coast_mission_1/art/tile_117_sd.png | Bin .../iron_coast_mission_1/art/tile_118_hd.png | Bin .../iron_coast_mission_1/art/tile_118_sd.png | Bin .../iron_coast_mission_1/art/tile_119_hd.png | Bin .../iron_coast_mission_1/art/tile_119_sd.png | Bin .../iron_coast_mission_1/art/tile_120_hd.png | Bin .../iron_coast_mission_1/art/tile_120_sd.png | Bin .../iron_coast_mission_1/art/tile_121_hd.png | Bin .../iron_coast_mission_1/art/tile_121_sd.png | Bin .../iron_coast_mission_1/art/tile_122_hd.png | Bin .../iron_coast_mission_1/art/tile_122_sd.png | Bin .../iron_coast_mission_1/art/tile_123_hd.png | Bin .../iron_coast_mission_1/art/tile_123_sd.png | Bin .../iron_coast_mission_1/art/tile_124_hd.png | Bin .../iron_coast_mission_1/art/tile_124_sd.png | Bin .../iron_coast_mission_1/art/tile_125_hd.png | Bin .../iron_coast_mission_1/art/tile_125_sd.png | Bin .../iron_coast_mission_1/art/tile_126_hd.png | Bin .../iron_coast_mission_1/art/tile_126_sd.png | Bin .../iron_coast_mission_1/art/tile_127_hd.png | Bin .../iron_coast_mission_1/art/tile_127_sd.png | Bin .../iron_coast_mission_1/art/tile_128_hd.png | Bin .../iron_coast_mission_1/art/tile_128_sd.png | Bin .../iron_coast_mission_1/art/tile_129_hd.png | Bin .../iron_coast_mission_1/art/tile_129_sd.png | Bin .../iron_coast_mission_1/art/tile_130_hd.png | Bin .../iron_coast_mission_1/art/tile_130_sd.png | Bin .../iron_coast_mission_1/art/tile_131_hd.png | Bin .../iron_coast_mission_1/art/tile_131_sd.png | Bin .../iron_coast_mission_1/art/tile_132_hd.png | Bin .../iron_coast_mission_1/art/tile_132_sd.png | Bin .../iron_coast_mission_1/art/tile_133_hd.png | Bin .../iron_coast_mission_1/art/tile_133_sd.png | Bin .../iron_coast_mission_1/art/tile_134_hd.png | Bin .../iron_coast_mission_1/art/tile_134_sd.png | Bin .../iron_coast_mission_1/art/tile_135_hd.png | Bin .../iron_coast_mission_1/art/tile_135_sd.png | Bin .../iron_coast_mission_1/art/tile_136_hd.png | Bin .../iron_coast_mission_1/art/tile_136_sd.png | Bin .../iron_coast_mission_1/art/tile_137_hd.png | Bin .../iron_coast_mission_1/art/tile_137_sd.png | Bin .../iron_coast_mission_1/art/tile_138_hd.png | Bin .../iron_coast_mission_1/art/tile_138_sd.png | Bin .../iron_coast_mission_1/art/tile_139_hd.png | Bin .../iron_coast_mission_1/art/tile_139_sd.png | Bin .../iron_coast_mission_1/art/tile_140_hd.png | Bin .../iron_coast_mission_1/art/tile_140_sd.png | Bin .../iron_coast_mission_1/art/tile_141_hd.png | Bin .../iron_coast_mission_1/art/tile_141_sd.png | Bin .../iron_coast_mission_1/art/tile_142_hd.png | Bin .../iron_coast_mission_1/art/tile_142_sd.png | Bin .../iron_coast_mission_1/art/tile_143_hd.png | Bin .../iron_coast_mission_1/art/tile_143_sd.png | Bin .../iron_coast_mission_1/art/tile_144_hd.png | Bin .../iron_coast_mission_1/art/tile_144_sd.png | Bin .../iron_coast_mission_1/art/tile_145_hd.png | Bin .../iron_coast_mission_1/art/tile_145_sd.png | Bin .../iron_coast_mission_1/art/tile_146_hd.png | Bin .../iron_coast_mission_1/art/tile_146_sd.png | Bin .../iron_coast_mission_1/art/tile_147_hd.png | Bin .../iron_coast_mission_1/art/tile_147_sd.png | Bin .../iron_coast_mission_1/art/tile_148_hd.png | Bin .../iron_coast_mission_1/art/tile_148_sd.png | Bin .../iron_coast_mission_1/art/tile_149_hd.png | Bin .../iron_coast_mission_1/art/tile_149_sd.png | Bin .../iron_coast_mission_1/art/tile_150_hd.png | Bin .../iron_coast_mission_1/art/tile_150_sd.png | Bin .../iron_coast_mission_1/art/tile_151_hd.png | Bin .../iron_coast_mission_1/art/tile_151_sd.png | Bin .../iron_coast_mission_1/art/tile_152_hd.png | Bin .../iron_coast_mission_1/art/tile_152_sd.png | Bin .../iron_coast_mission_1/art/tile_153_hd.png | Bin .../iron_coast_mission_1/art/tile_153_sd.png | Bin .../iron_coast_mission_1/art/tile_154_hd.png | Bin .../iron_coast_mission_1/art/tile_154_sd.png | Bin .../iron_coast_mission_1/art/tile_155_hd.png | Bin .../iron_coast_mission_1/art/tile_155_sd.png | Bin .../iron_coast_mission_1/art/tile_156_hd.png | Bin .../iron_coast_mission_1/art/tile_156_sd.png | Bin .../iron_coast_mission_1/art/tile_157_hd.png | Bin .../iron_coast_mission_1/art/tile_157_sd.png | Bin .../iron_coast_mission_1/art/tile_158_hd.png | Bin .../iron_coast_mission_1/art/tile_158_sd.png | Bin .../iron_coast_mission_1/art/tile_159_hd.png | Bin .../iron_coast_mission_1/art/tile_159_sd.png | Bin .../iron_coast_mission_1/art/tile_160_hd.png | Bin .../iron_coast_mission_1/art/tile_160_sd.png | Bin .../iron_coast_mission_1/art/tile_161_hd.png | Bin .../iron_coast_mission_1/art/tile_161_sd.png | Bin .../iron_coast_mission_1/art/tile_162_hd.png | Bin .../iron_coast_mission_1/art/tile_162_sd.png | Bin .../iron_coast_mission_1/art/tile_163_hd.png | Bin .../iron_coast_mission_1/art/tile_163_sd.png | Bin .../iron_coast_mission_1/art/tile_164_hd.png | Bin .../iron_coast_mission_1/art/tile_164_sd.png | Bin .../iron_coast_mission_1/art/tile_165_hd.png | Bin .../iron_coast_mission_1/art/tile_165_sd.png | Bin .../iron_coast_mission_1/art/tile_166_hd.png | Bin .../iron_coast_mission_1/art/tile_166_sd.png | Bin .../iron_coast_mission_1/art/tile_167_hd.png | Bin .../iron_coast_mission_1/art/tile_167_sd.png | Bin .../iron_coast_mission_1/art/tile_168_hd.png | Bin .../iron_coast_mission_1/art/tile_168_sd.png | Bin .../iron_coast_mission_1/art/tile_169_hd.png | Bin .../iron_coast_mission_1/art/tile_169_sd.png | Bin .../iron_coast_mission_1/art/tile_170_hd.png | Bin .../iron_coast_mission_1/art/tile_170_sd.png | Bin .../iron_coast_mission_1/art/tile_171_hd.png | Bin .../iron_coast_mission_1/art/tile_171_sd.png | Bin .../iron_coast_mission_1/art/tile_172_hd.png | Bin .../iron_coast_mission_1/art/tile_172_sd.png | Bin .../iron_coast_mission_1/art/tile_173_hd.png | Bin .../iron_coast_mission_1/art/tile_173_sd.png | Bin .../iron_coast_mission_1/art/tile_174_hd.png | Bin .../iron_coast_mission_1/art/tile_174_sd.png | Bin .../iron_coast_mission_1/art/tile_175_hd.png | Bin .../iron_coast_mission_1/art/tile_175_sd.png | Bin .../iron_coast_mission_1/art/tile_176_hd.png | Bin .../iron_coast_mission_1/art/tile_176_sd.png | Bin .../iron_coast_mission_1/art/tile_177_hd.png | Bin .../iron_coast_mission_1/art/tile_177_sd.png | Bin .../iron_coast_mission_1/art/tile_178_hd.png | Bin .../iron_coast_mission_1/art/tile_178_sd.png | Bin .../iron_coast_mission_1/art/tile_179_hd.png | Bin .../iron_coast_mission_1/art/tile_179_sd.png | Bin .../iron_coast_mission_1/art/tile_180_hd.png | Bin .../iron_coast_mission_1/art/tile_180_sd.png | Bin .../iron_coast_mission_1/art/tile_181_hd.png | Bin .../iron_coast_mission_1/art/tile_181_sd.png | Bin .../iron_coast_mission_1/art/tile_182_hd.png | Bin .../iron_coast_mission_1/art/tile_182_sd.png | Bin .../iron_coast_mission_1/art/tile_183_hd.png | Bin .../iron_coast_mission_1/art/tile_183_sd.png | Bin .../iron_coast_mission_1/art/tile_184_hd.png | Bin .../iron_coast_mission_1/art/tile_184_sd.png | Bin .../iron_coast_mission_1/art/tile_185_hd.png | Bin .../iron_coast_mission_1/art/tile_185_sd.png | Bin .../iron_coast_mission_1/art/tile_186_hd.png | Bin .../iron_coast_mission_1/art/tile_186_sd.png | Bin .../iron_coast_mission_1/art/tile_187_hd.png | Bin .../iron_coast_mission_1/art/tile_187_sd.png | Bin .../iron_coast_mission_1/art/tile_188_hd.png | Bin .../iron_coast_mission_1/art/tile_188_sd.png | Bin .../iron_coast_mission_1/art/tile_189_hd.png | Bin .../iron_coast_mission_1/art/tile_189_sd.png | Bin .../iron_coast_mission_1/art/tile_190_hd.png | Bin .../iron_coast_mission_1/art/tile_190_sd.png | Bin .../iron_coast_mission_1/art/tile_191_hd.png | Bin .../iron_coast_mission_1/art/tile_191_sd.png | Bin .../iron_coast_mission_1/art/tile_192_hd.png | Bin .../iron_coast_mission_1/art/tile_192_sd.png | Bin .../iron_coast_mission_1/art/tile_193_hd.png | Bin .../iron_coast_mission_1/art/tile_193_sd.png | Bin .../iron_coast_mission_1/art/tile_194_hd.png | Bin .../iron_coast_mission_1/art/tile_194_sd.png | Bin .../iron_coast_mission_1/art/tile_195_hd.png | Bin .../iron_coast_mission_1/art/tile_195_sd.png | Bin .../iron_coast_mission_1/art/tile_196_hd.png | Bin .../iron_coast_mission_1/art/tile_196_sd.png | Bin .../iron_coast_mission_1/art/tile_197_hd.png | Bin .../iron_coast_mission_1/art/tile_197_sd.png | Bin .../iron_coast_mission_1/art/tile_198_hd.png | Bin .../iron_coast_mission_1/art/tile_198_sd.png | Bin .../iron_coast_mission_1/art/tile_199_hd.png | Bin .../iron_coast_mission_1/art/tile_199_sd.png | Bin .../iron_coast_mission_1/art/tile_200_hd.png | Bin .../iron_coast_mission_1/art/tile_200_sd.png | Bin .../iron_coast_mission_1/art/tile_201_hd.png | Bin .../iron_coast_mission_1/art/tile_201_sd.png | Bin .../iron_coast_mission_1/art/tile_202_hd.png | Bin .../iron_coast_mission_1/art/tile_202_sd.png | Bin .../iron_coast_mission_1/art/tile_203_hd.png | Bin .../iron_coast_mission_1/art/tile_203_sd.png | Bin .../iron_coast_mission_1/art/tile_204_hd.png | Bin .../iron_coast_mission_1/art/tile_204_sd.png | Bin .../iron_coast_mission_1/art/tile_205_hd.png | Bin .../iron_coast_mission_1/art/tile_205_sd.png | Bin .../iron_coast_mission_1/art/tile_206_hd.png | Bin .../iron_coast_mission_1/art/tile_206_sd.png | Bin .../iron_coast_mission_1/art/tile_207_hd.png | Bin .../iron_coast_mission_1/art/tile_207_sd.png | Bin .../iron_coast_mission_1/art/tile_208_hd.png | Bin .../iron_coast_mission_1/art/tile_208_sd.png | Bin .../iron_coast_mission_1/art/tile_209_hd.png | Bin .../iron_coast_mission_1/art/tile_209_sd.png | Bin .../iron_coast_mission_1/art/tile_210_hd.png | Bin .../iron_coast_mission_1/art/tile_210_sd.png | Bin .../iron_coast_mission_1/art/tile_211_hd.png | Bin .../iron_coast_mission_1/art/tile_211_sd.png | Bin .../iron_coast_mission_1/art/tile_212_hd.png | Bin .../iron_coast_mission_1/art/tile_212_sd.png | Bin .../iron_coast_mission_1/art/tile_213_hd.png | Bin .../iron_coast_mission_1/art/tile_213_sd.png | Bin .../iron_coast_mission_1/art/tile_214_hd.png | Bin .../iron_coast_mission_1/art/tile_214_sd.png | Bin .../iron_coast_mission_1/art/tile_215_hd.png | Bin .../iron_coast_mission_1/art/tile_215_sd.png | Bin .../iron_coast_mission_1/asset-report.json | 0 .../iron_coast_mission_1/audio/approach.wav | Bin .../iron_coast_mission_1/audio/boss_alarm.wav | Bin .../iron_coast_mission_1/audio/depot_open.wav | Bin .../iron_coast_mission_1/audio/dock_alarm.wav | Bin .../iron_coast_mission_1/audio/repair_use.wav | Bin .../iron_coast_mission_1/audio/victory.wav | Bin .../mods/iron_coast_mission_1/build_assets.py | 0 .../mods/iron_coast_mission_1/content.json | 5 +- .../mods/iron_coast_mission_1/mod.toml | 0 .../mods/iron_coast_mission_1/prompts.json | 0 .../mods/iron_coast_mission_1/prompts.md | 0 .../iron_coast_mission_1/scripts/depot.rhai | 0 .../iron_coast_mission_1/scripts/mission.rhai | 21 +- .../iron_coast_mission_1/scripts/repair.rhai | 0 .../mods/iron_coast_scout/README.md | 2 +- .../mods/iron_coast_scout/art-qa.json | 0 .../mods/iron_coast_scout/art/preview.png | Bin .../art/projectile-generated-v1.png | Bin .../iron_coast_scout/art/projectile-hd.png | Bin .../iron_coast_scout/art/projectile-sd.png | Bin .../art/unit-generated-v1.png | Bin .../mods/iron_coast_scout/art/unit-hd.png | Bin .../mods/iron_coast_scout/art/unit-sd.png | Bin .../mods/iron_coast_scout/audio-recipe.json | 0 .../mods/iron_coast_scout/audio/death.wav | Bin .../mods/iron_coast_scout/audio/shot.wav | Bin .../mods/iron_coast_scout}/build_assets.py | 4 +- .../mods/iron_coast_scout/content.json | 0 .../mods/iron_coast_scout/mod.toml | 0 .../prompts/projectile-hd-v1.txt | 0 .../iron_coast_scout/prompts/unit-hd-v1.txt | 0 .../mods/iron_coast_scout/provenance.json | 0 .../mods/iron_coast_scout/scripts/ai.rhai | 0 .../mods/iron_coast_turret/README.md | 2 +- .../mods/iron_coast_turret/art-qa.json | 0 .../mods/iron_coast_turret/art/preview.png | Bin .../art/projectile-generated-v1.png | Bin .../iron_coast_turret/art/projectile-hd.png | Bin .../iron_coast_turret/art/projectile-sd.png | Bin .../art/unit-generated-v1.png | Bin .../mods/iron_coast_turret/art/unit-hd.png | Bin .../mods/iron_coast_turret/art/unit-sd.png | Bin .../mods/iron_coast_turret/audio-recipe.json | 0 .../mods/iron_coast_turret/audio/death.wav | Bin .../mods/iron_coast_turret/audio/shot.wav | Bin .../mods/iron_coast_turret}/build_assets.py | 4 +- .../mods/iron_coast_turret/content.json | 0 .../mods/iron_coast_turret/mod.toml | 0 .../prompts/projectile-hd-v1.txt | 0 .../iron_coast_turret/prompts/unit-hd-v1.txt | 0 .../mods/iron_coast_turret/provenance.json | 0 .../mods/iron_coast_turret/scripts/ai.rhai | 0 .../mods/pulse_cannon/ART_PROVENANCE.md | 0 .../mods/pulse_cannon/README.md | 4 +- .../mods/pulse_cannon/art-provenance.json | 0 .../art/equipment-generated-source.png | Bin .../mods/pulse_cannon/art/equipment-hd.png | Bin .../mods/pulse_cannon/art/equipment-sd.png | Bin .../art/projectile-generated-source.png | Bin .../mods/pulse_cannon/art/projectile-hd.png | Bin .../mods/pulse_cannon/art/projectile-sd.png | Bin .../mods/pulse_cannon/audio/fire.wav | Bin .../mods/pulse_cannon/audio/impact.wav | Bin .../mods/pulse_cannon/content.json | 5 +- .../mods/pulse_cannon/generate_audio.py | 0 .../mods/pulse_cannon/mod.toml | 0 .../mods/pulse_cannon/prepare_art.rs | 0 .../mods/pulse_cannon/scripts/equipment.rhai | 0 .../mods/pulse_cannon/scripts/weapon.rhai | 5 +- .../mods/seeker_swarm/ART_PROVENANCE.md | 0 .../mods/seeker_swarm/README.md | 4 +- .../mods/seeker_swarm/art-provenance.json | 0 .../art/equipment-generated-source.png | Bin .../mods/seeker_swarm/art/equipment-hd.png | Bin .../mods/seeker_swarm/art/equipment-sd.png | Bin .../art/projectile-generated-source.png | Bin .../mods/seeker_swarm/art/projectile-hd.png | Bin .../mods/seeker_swarm/art/projectile-sd.png | Bin .../mods/seeker_swarm/audio/fire.wav | Bin .../mods/seeker_swarm/audio/impact.wav | Bin .../mods/seeker_swarm/content.json | 5 +- .../mods/seeker_swarm/generate_audio.py | 0 .../mods/seeker_swarm/mod.toml | 0 .../mods/seeker_swarm/prepare_art.rs | 0 .../mods/seeker_swarm/scripts/equipment.rhai | 0 .../mods/seeker_swarm/scripts/weapon.rhai | 5 +- RustRaptor/run.sh | 5 + .../schemas}/campaign-v1.json | 0 .../schemas}/content-v1.json | 5 + {schemas => RustRaptor/schemas}/enemy-v1.json | 0 {schemas => RustRaptor/schemas}/hook-v1.json | 0 {schemas => RustRaptor/schemas}/item-v1.json | 5 + .../schemas}/mission-v1.json | 0 {schemas => RustRaptor/schemas}/mod-v1.json | 2 +- {schemas => RustRaptor/schemas}/sound-v1.json | 0 .../schemas}/weapon-projectile-v1.json | 0 {src => RustRaptor/src}/assets.rs | 0 {src => RustRaptor/src}/audio.rs | 61 +- .../src}/audio/LICENSE-LGPL-2.1.txt | 0 {src => RustRaptor/src}/audio/opl3.rs | 0 {src => RustRaptor/src}/cinematics.rs | 0 {src => RustRaptor/src}/content.rs | 55 +- RustRaptor/src/desktop.rs | 5225 +++++++++++++++++ {src => RustRaptor/src}/hd.rs | 0 {src => RustRaptor/src}/legacy_tables.rs | 0 {src => RustRaptor/src}/lib.rs | 1 + {src => RustRaptor/src}/main.rs | 23 +- RustRaptor/src/mod_selection.rs | 146 + {src => RustRaptor/src}/modding.rs | 2 +- {src => RustRaptor/src}/persistence.rs | 343 +- {src => RustRaptor/src}/render.rs | 6 + {src => RustRaptor/src}/simulation.rs | 111 +- RustRaptor/src/sound.rs | 241 + {tests => RustRaptor/tests}/audio.rs | 105 +- {tests => RustRaptor/tests}/campaigns.rs | 4 +- {tests => RustRaptor/tests}/core.rs | 36 +- {tests => RustRaptor/tests}/enemies_mods.rs | 6 +- {tests => RustRaptor/tests}/first_mission.rs | 4 +- {tests => RustRaptor/tests}/gameplay.rs | 0 {tests => RustRaptor/tests}/iron_coast.rs | 99 +- RustRaptor/tests/mod_selection.rs | 181 + {tests => RustRaptor/tests}/modding.rs | 2 +- RustRaptor/tests/shields.rs | 532 ++ RustRaptor/tests/sound.rs | 330 ++ {tests => RustRaptor/tests}/transitions.rs | 0 {tests => RustRaptor/tests}/visuals.rs | 8 +- RustRaptor/tests/weapon_balance.rs | 225 + {tests => RustRaptor/tests}/weapons_mods.rs | 43 +- {tools => RustRaptor/tools}/campaign_check.rs | 0 .../tools}/compare_replays.py | 0 .../tools}/generate_legacy_tables.py | 5 +- {tools => RustRaptor/tools}/hd_art_source.py | 2 +- docs/README.md | 11 +- docs/building.md | 6 +- docs/rust-rewrite.md | 85 - play.sh | 6 +- src/desktop.rs | 2147 ------- src/sound.rs | 96 - tests/sound.rs | 119 - 649 files changed, 8652 insertions(+), 2677 deletions(-) delete mode 100644 PLAN.md rename Cargo.lock => RustRaptor/Cargo.lock (100%) rename Cargo.toml => RustRaptor/Cargo.toml (100%) create mode 100644 RustRaptor/LICENSE create mode 100644 RustRaptor/PLAN.md create mode 100644 RustRaptor/README.md rename build.rs => RustRaptor/build.rs (100%) rename {docs => RustRaptor/docs}/campaign-validation.md (86%) rename {docs => RustRaptor/docs}/first-mission-validation.md (96%) rename {docs => RustRaptor/docs}/hd-art.md (74%) rename {docs => RustRaptor/docs}/iron-coast.md (79%) rename {docs => RustRaptor/docs}/modding-api.md (80%) create mode 100644 RustRaptor/docs/performance.md create mode 100644 RustRaptor/docs/rust-rewrite.md create mode 100644 RustRaptor/docs/sound-validation.md rename {docs => RustRaptor/docs}/transition-validation.md (93%) rename {docs => RustRaptor/docs}/visual-validation.md (91%) create mode 100644 RustRaptor/docs/weapon-balance.md rename {examples => RustRaptor}/mods/aurora/README.md (59%) rename {examples => RustRaptor}/mods/aurora/content.json (100%) rename {examples => RustRaptor}/mods/aurora/mod.toml (100%) rename {examples => RustRaptor}/mods/aurora/scripts/cell.rhai (100%) rename {examples => RustRaptor}/mods/aurora/scripts/drone.rhai (100%) rename {examples => RustRaptor}/mods/aurora/scripts/mission.rhai (100%) rename {examples => RustRaptor}/mods/aurora/scripts/weapon.rhai (100%) rename {examples => RustRaptor}/mods/ion_lance/ART_PROVENANCE.md (100%) rename {examples => RustRaptor}/mods/ion_lance/README.md (72%) rename {examples => RustRaptor}/mods/ion_lance/art-provenance.json (100%) rename {examples => RustRaptor}/mods/ion_lance/art/equipment-generated-source.png (100%) rename {examples => RustRaptor}/mods/ion_lance/art/equipment-hd.png (100%) rename {examples => RustRaptor}/mods/ion_lance/art/equipment-sd.png (100%) rename {examples => RustRaptor}/mods/ion_lance/art/projectile-generated-source.png (100%) rename {examples => RustRaptor}/mods/ion_lance/art/projectile-hd.png (100%) rename {examples => RustRaptor}/mods/ion_lance/art/projectile-sd.png (100%) rename {examples => RustRaptor}/mods/ion_lance/audio/fire.wav (100%) rename {examples => RustRaptor}/mods/ion_lance/audio/impact.wav (100%) rename {examples => RustRaptor}/mods/ion_lance/content.json (82%) rename {examples => RustRaptor}/mods/ion_lance/generate_audio.py (100%) rename {examples => RustRaptor}/mods/ion_lance/mod.toml (100%) rename {examples => RustRaptor}/mods/ion_lance/prepare_art.rs (100%) rename {examples => RustRaptor}/mods/ion_lance/scripts/equipment.rhai (100%) rename {examples => RustRaptor}/mods/ion_lance/scripts/weapon.rhai (80%) rename {examples => RustRaptor}/mods/iron_coast_boss/README.md (88%) rename {examples => RustRaptor}/mods/iron_coast_boss/art-qa.json (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/art/preview.png (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/art/projectile-generated-v1.png (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/art/projectile-hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/art/projectile-sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/art/unit-charge-generated-v1.png (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/art/unit-generated-v1.png (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/art/unit-hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/art/unit-sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/audio-recipe.json (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/audio/death.wav (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/audio/shot.wav (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/build_assets.py (97%) rename {examples => RustRaptor}/mods/iron_coast_boss/content.json (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/mod.toml (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/prompts/projectile-hd-v1.txt (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/prompts/unit-charge-v1.txt (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/prompts/unit-hd-v1.txt (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/provenance.json (100%) rename {examples => RustRaptor}/mods/iron_coast_boss/scripts/ai.rhai (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/README.md (78%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/briefing-source-hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/briefing_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/briefing_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/depot-source-hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/depot_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/depot_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/repair_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/repair_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/terrain-preview-hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/terrain-preview-sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/terrain-source-hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_000_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_000_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_001_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_001_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_002_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_002_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_003_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_003_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_004_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_004_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_005_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_005_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_006_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_006_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_007_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_007_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_008_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_008_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_009_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_009_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_010_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_010_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_011_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_011_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_012_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_012_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_013_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_013_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_014_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_014_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_015_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_015_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_016_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_016_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_017_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_017_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_018_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_018_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_019_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_019_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_020_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_020_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_021_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_021_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_022_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_022_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_023_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_023_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_024_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_024_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_025_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_025_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_026_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_026_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_027_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_027_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_028_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_028_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_029_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_029_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_030_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_030_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_031_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_031_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_032_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_032_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_033_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_033_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_034_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_034_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_035_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_035_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_036_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_036_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_037_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_037_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_038_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_038_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_039_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_039_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_040_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_040_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_041_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_041_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_042_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_042_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_043_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_043_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_044_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_044_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_045_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_045_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_046_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_046_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_047_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_047_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_048_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_048_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_049_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_049_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_050_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_050_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_051_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_051_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_052_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_052_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_053_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_053_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_054_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_054_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_055_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_055_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_056_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_056_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_057_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_057_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_058_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_058_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_059_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_059_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_060_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_060_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_061_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_061_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_062_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_062_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_063_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_063_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_064_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_064_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_065_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_065_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_066_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_066_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_067_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_067_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_068_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_068_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_069_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_069_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_070_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_070_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_071_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_071_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_072_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_072_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_073_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_073_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_074_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_074_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_075_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_075_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_076_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_076_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_077_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_077_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_078_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_078_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_079_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_079_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_080_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_080_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_081_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_081_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_082_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_082_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_083_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_083_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_084_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_084_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_085_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_085_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_086_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_086_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_087_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_087_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_088_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_088_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_089_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_089_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_090_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_090_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_091_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_091_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_092_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_092_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_093_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_093_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_094_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_094_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_095_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_095_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_096_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_096_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_097_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_097_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_098_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_098_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_099_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_099_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_100_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_100_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_101_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_101_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_102_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_102_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_103_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_103_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_104_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_104_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_105_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_105_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_106_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_106_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_107_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_107_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_108_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_108_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_109_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_109_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_110_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_110_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_111_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_111_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_112_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_112_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_113_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_113_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_114_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_114_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_115_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_115_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_116_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_116_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_117_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_117_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_118_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_118_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_119_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_119_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_120_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_120_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_121_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_121_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_122_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_122_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_123_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_123_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_124_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_124_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_125_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_125_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_126_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_126_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_127_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_127_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_128_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_128_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_129_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_129_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_130_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_130_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_131_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_131_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_132_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_132_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_133_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_133_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_134_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_134_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_135_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_135_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_136_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_136_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_137_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_137_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_138_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_138_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_139_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_139_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_140_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_140_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_141_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_141_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_142_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_142_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_143_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_143_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_144_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_144_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_145_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_145_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_146_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_146_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_147_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_147_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_148_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_148_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_149_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_149_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_150_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_150_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_151_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_151_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_152_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_152_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_153_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_153_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_154_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_154_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_155_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_155_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_156_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_156_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_157_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_157_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_158_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_158_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_159_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_159_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_160_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_160_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_161_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_161_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_162_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_162_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_163_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_163_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_164_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_164_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_165_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_165_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_166_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_166_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_167_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_167_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_168_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_168_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_169_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_169_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_170_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_170_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_171_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_171_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_172_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_172_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_173_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_173_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_174_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_174_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_175_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_175_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_176_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_176_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_177_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_177_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_178_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_178_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_179_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_179_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_180_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_180_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_181_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_181_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_182_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_182_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_183_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_183_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_184_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_184_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_185_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_185_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_186_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_186_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_187_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_187_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_188_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_188_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_189_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_189_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_190_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_190_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_191_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_191_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_192_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_192_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_193_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_193_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_194_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_194_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_195_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_195_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_196_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_196_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_197_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_197_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_198_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_198_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_199_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_199_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_200_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_200_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_201_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_201_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_202_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_202_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_203_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_203_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_204_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_204_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_205_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_205_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_206_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_206_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_207_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_207_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_208_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_208_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_209_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_209_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_210_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_210_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_211_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_211_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_212_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_212_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_213_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_213_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_214_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_214_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_215_hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/art/tile_215_sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/asset-report.json (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/audio/approach.wav (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/audio/boss_alarm.wav (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/audio/depot_open.wav (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/audio/dock_alarm.wav (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/audio/repair_use.wav (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/audio/victory.wav (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/build_assets.py (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/content.json (99%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/mod.toml (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/prompts.json (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/prompts.md (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/scripts/depot.rhai (100%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/scripts/mission.rhai (76%) rename {examples => RustRaptor}/mods/iron_coast_mission_1/scripts/repair.rhai (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/README.md (88%) rename {examples => RustRaptor}/mods/iron_coast_scout/art-qa.json (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/art/preview.png (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/art/projectile-generated-v1.png (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/art/projectile-hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/art/projectile-sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/art/unit-generated-v1.png (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/art/unit-hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/art/unit-sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/audio-recipe.json (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/audio/death.wav (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/audio/shot.wav (100%) rename {examples/mods/iron_coast_turret => RustRaptor/mods/iron_coast_scout}/build_assets.py (97%) rename {examples => RustRaptor}/mods/iron_coast_scout/content.json (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/mod.toml (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/prompts/projectile-hd-v1.txt (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/prompts/unit-hd-v1.txt (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/provenance.json (100%) rename {examples => RustRaptor}/mods/iron_coast_scout/scripts/ai.rhai (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/README.md (88%) rename {examples => RustRaptor}/mods/iron_coast_turret/art-qa.json (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/art/preview.png (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/art/projectile-generated-v1.png (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/art/projectile-hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/art/projectile-sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/art/unit-generated-v1.png (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/art/unit-hd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/art/unit-sd.png (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/audio-recipe.json (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/audio/death.wav (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/audio/shot.wav (100%) rename {examples/mods/iron_coast_scout => RustRaptor/mods/iron_coast_turret}/build_assets.py (97%) rename {examples => RustRaptor}/mods/iron_coast_turret/content.json (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/mod.toml (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/prompts/projectile-hd-v1.txt (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/prompts/unit-hd-v1.txt (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/provenance.json (100%) rename {examples => RustRaptor}/mods/iron_coast_turret/scripts/ai.rhai (100%) rename {examples => RustRaptor}/mods/pulse_cannon/ART_PROVENANCE.md (100%) rename {examples => RustRaptor}/mods/pulse_cannon/README.md (76%) rename {examples => RustRaptor}/mods/pulse_cannon/art-provenance.json (100%) rename {examples => RustRaptor}/mods/pulse_cannon/art/equipment-generated-source.png (100%) rename {examples => RustRaptor}/mods/pulse_cannon/art/equipment-hd.png (100%) rename {examples => RustRaptor}/mods/pulse_cannon/art/equipment-sd.png (100%) rename {examples => RustRaptor}/mods/pulse_cannon/art/projectile-generated-source.png (100%) rename {examples => RustRaptor}/mods/pulse_cannon/art/projectile-hd.png (100%) rename {examples => RustRaptor}/mods/pulse_cannon/art/projectile-sd.png (100%) rename {examples => RustRaptor}/mods/pulse_cannon/audio/fire.wav (100%) rename {examples => RustRaptor}/mods/pulse_cannon/audio/impact.wav (100%) rename {examples => RustRaptor}/mods/pulse_cannon/content.json (83%) rename {examples => RustRaptor}/mods/pulse_cannon/generate_audio.py (100%) rename {examples => RustRaptor}/mods/pulse_cannon/mod.toml (100%) rename {examples => RustRaptor}/mods/pulse_cannon/prepare_art.rs (100%) rename {examples => RustRaptor}/mods/pulse_cannon/scripts/equipment.rhai (100%) rename {examples => RustRaptor}/mods/pulse_cannon/scripts/weapon.rhai (86%) rename {examples => RustRaptor}/mods/seeker_swarm/ART_PROVENANCE.md (100%) rename {examples => RustRaptor}/mods/seeker_swarm/README.md (71%) rename {examples => RustRaptor}/mods/seeker_swarm/art-provenance.json (100%) rename {examples => RustRaptor}/mods/seeker_swarm/art/equipment-generated-source.png (100%) rename {examples => RustRaptor}/mods/seeker_swarm/art/equipment-hd.png (100%) rename {examples => RustRaptor}/mods/seeker_swarm/art/equipment-sd.png (100%) rename {examples => RustRaptor}/mods/seeker_swarm/art/projectile-generated-source.png (100%) rename {examples => RustRaptor}/mods/seeker_swarm/art/projectile-hd.png (100%) rename {examples => RustRaptor}/mods/seeker_swarm/art/projectile-sd.png (100%) rename {examples => RustRaptor}/mods/seeker_swarm/audio/fire.wav (100%) rename {examples => RustRaptor}/mods/seeker_swarm/audio/impact.wav (100%) rename {examples => RustRaptor}/mods/seeker_swarm/content.json (83%) rename {examples => RustRaptor}/mods/seeker_swarm/generate_audio.py (100%) rename {examples => RustRaptor}/mods/seeker_swarm/mod.toml (100%) rename {examples => RustRaptor}/mods/seeker_swarm/prepare_art.rs (100%) rename {examples => RustRaptor}/mods/seeker_swarm/scripts/equipment.rhai (100%) rename {examples => RustRaptor}/mods/seeker_swarm/scripts/weapon.rhai (86%) create mode 100755 RustRaptor/run.sh rename {schemas => RustRaptor/schemas}/campaign-v1.json (100%) rename {schemas => RustRaptor/schemas}/content-v1.json (98%) rename {schemas => RustRaptor/schemas}/enemy-v1.json (100%) rename {schemas => RustRaptor/schemas}/hook-v1.json (100%) rename {schemas => RustRaptor/schemas}/item-v1.json (94%) rename {schemas => RustRaptor/schemas}/mission-v1.json (100%) rename {schemas => RustRaptor/schemas}/mod-v1.json (93%) rename {schemas => RustRaptor/schemas}/sound-v1.json (100%) rename {schemas => RustRaptor/schemas}/weapon-projectile-v1.json (100%) rename {src => RustRaptor/src}/assets.rs (100%) rename {src => RustRaptor/src}/audio.rs (93%) rename {src => RustRaptor/src}/audio/LICENSE-LGPL-2.1.txt (100%) rename {src => RustRaptor/src}/audio/opl3.rs (100%) rename {src => RustRaptor/src}/cinematics.rs (100%) rename {src => RustRaptor/src}/content.rs (89%) create mode 100644 RustRaptor/src/desktop.rs rename {src => RustRaptor/src}/hd.rs (100%) rename {src => RustRaptor/src}/legacy_tables.rs (100%) rename {src => RustRaptor/src}/lib.rs (91%) rename {src => RustRaptor/src}/main.rs (93%) create mode 100644 RustRaptor/src/mod_selection.rs rename {src => RustRaptor/src}/modding.rs (99%) rename {src => RustRaptor/src}/persistence.rs (62%) rename {src => RustRaptor/src}/render.rs (98%) rename {src => RustRaptor/src}/simulation.rs (95%) create mode 100644 RustRaptor/src/sound.rs rename {tests => RustRaptor/tests}/audio.rs (79%) rename {tests => RustRaptor/tests}/campaigns.rs (98%) rename {tests => RustRaptor/tests}/core.rs (87%) rename {tests => RustRaptor/tests}/enemies_mods.rs (98%) rename {tests => RustRaptor/tests}/first_mission.rs (99%) rename {tests => RustRaptor/tests}/gameplay.rs (100%) rename {tests => RustRaptor/tests}/iron_coast.rs (81%) create mode 100644 RustRaptor/tests/mod_selection.rs rename {tests => RustRaptor/tests}/modding.rs (99%) create mode 100644 RustRaptor/tests/shields.rs create mode 100644 RustRaptor/tests/sound.rs rename {tests => RustRaptor/tests}/transitions.rs (100%) rename {tests => RustRaptor/tests}/visuals.rs (99%) create mode 100644 RustRaptor/tests/weapon_balance.rs rename {tests => RustRaptor/tests}/weapons_mods.rs (90%) rename {tools => RustRaptor/tools}/campaign_check.rs (100%) rename {tools => RustRaptor/tools}/compare_replays.py (100%) rename {tools => RustRaptor/tools}/generate_legacy_tables.py (97%) rename {tools => RustRaptor/tools}/hd_art_source.py (96%) delete mode 100644 docs/rust-rewrite.md delete mode 100644 src/desktop.rs delete mode 100644 src/sound.rs delete mode 100644 tests/sound.rs diff --git a/.gitignore b/.gitignore index 6f02e77c..ae386025 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Derived game data — extracted from the copyrighted originals, never commit data/ +RustRaptor/data/ # Vendored raptor build output raptor/build/ @@ -13,9 +14,12 @@ __pycache__/ # Rust builds and locally derived reference/preview data target/ +RustRaptor/target/ +RustRaptor/dist/ +RustRaptor/.reference/ .reference/ derived/ # Decoded original style references are not shipped mod artwork. -examples/mods/*/art/source/ +RustRaptor/mods/*/art/source/ diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index d3582174..00000000 --- a/PLAN.md +++ /dev/null @@ -1,30 +0,0 @@ -# Rust rewrite milestones - -The C++ engine remains playable and retained as a reference. The Rust engine runs independently alongside it on branch `codex/rust-rewrite`. Both are selectable through `play.sh`. Original catalog/reference assets remain ignored; authored mod images and sounds are tracked in Git. - -The registered game has three sectors with nine waves each. Archive slots are asset containers, not five independent campaigns. - -## Implemented - -- Checked asset decoders, typed namespaced registries and original content registration. -- Independent integer simulation at 70/3 Hz; deterministic RNG and SDL2 input/interpolated indexed rendering. -- Original / Scale2x / Scale3x / Scale4x modes, joined terrain filtering, sharp bitmap text and separate derived cache. -- Rust MUS, GENMIDI FM synthesis and digital DSP mixing, with native PCM comparisons. -- Menus, shop, equipment, mission selection, pilot slots, atomic saves, read-only legacy import and original AGX cinematics. -- Local mods, dependencies/conflicts, explicit overrides/priority/hashes, bounded deterministic Rhai hooks and transactional commands. -- In-game enable/disable and pending changes, save diagnostics, schemas, validation CLI and scripted Aurora example. -- Optional source-hashed HD loader/setting; one accepted player-frame proof with original fallback. -- C++/Rust replay recording and comparison tools, synthetic tests and local-original-data checks. - -## Acceptance gates - -1. **Foundation and complete playable first mission:** verify the entire mission through its boss, damage, rewards, collision/order and RNG against the rebuilt reference. Short replay equality is evidence, not completion of this gate. -2. **Complete game:** verify all 27 waves and four difficulties, equipment, training/campaign progression, cinematics, demos, menus and legacy imports. -3. **Modding release:** finish lifecycle/error/save acceptance and stabilize API version 1. -4. **Visual completion and cutover:** approve complete sprite sequences, check palette/layer/clipping/seam fidelity at 1080p and measure display performance. Make Rust the default only after preceding gates pass. - -Not all gates have passed. See [validation instructions](docs/rust-rewrite.md) and [HD coverage](docs/hd-art.md). Online mod distribution, an editor, total conversions, external MIDI/SoundFonts and additional desktop targets remain outside scope. - -## New content - -Separate agents are building Iron Coast mission 1, three individual weapon mods and three individual enemy mods using the public API. Each package will ship local SD/HD artwork and procedurally generated sound effects, with definitions, scripts, images and sounds tracked together in Git. These packages remain in development until validation and play tests pass. diff --git a/README.md b/README.md index fb6f2582..9562d0c0 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,19 @@ # RaptorReplica -A Linux replica of Raptor: Call of the Shadows, with the retained C++ engine and an independent Rust engine under development. +[RustRaptor](RustRaptor/README.md) contains the independent Rust game, bundled mods, +SD/HD artwork, sound effects, schemas, tests and build tools. Rust is the default: ```sh -./play.sh --engine rust -./play.sh --engine legacy +./play.sh ``` -The C++ engine remains the launcher default while Rust gameplay parity and full-game acceptance are verified. Rust includes SDL2 input, interpolated indexed rendering, audio, all 27 original mission definitions, deterministic simulation, pilot slots, separate saves, cinematics, an in-game mod manager, bounded Rhai scripting, crisp sprite filters and an optional HD artwork setting. +You can also run `./RustRaptor/run.sh`. The game loads your existing `data/` +catalog; choose bundled mods from its **Mods** menu. Mod images and sounds are +tracked in Git alongside their definitions and scripts. -The local HD proof replaces one player frame; other frames use original artwork. It is not a complete remaster. Longer reference replays, full campaign playthroughs and presentation fidelity remain in validation. +The retained source under `raptor/` is an optional reference. It is not required +to build or play RustRaptor. `./play.sh --engine legacy` runs that reference. -- [Building and running](docs/building.md) -- [Rust implementation and validation](docs/rust-rewrite.md) -- [Mod API and schemas](docs/modding-api.md) -- [Optional HD artwork](docs/hd-art.md) -- [Asset extraction](docs/asset-pipeline.md) -- [Milestones](PLAN.md) - -Code is GPL-2.0-or-later; the vendored Rust FM emulator is LGPL-2.1-or-later. Original game catalog assets, local HD remaster proofs, builds, caches and recordings stay outside Git. Authored mod SD/HD images and sounds are included in Git. Supply your own licensed data in `data/`. +See [Rust build and validation](RustRaptor/docs/rust-rewrite.md), +[modding API](RustRaptor/docs/modding-api.md), and +[Iron Coast packages](RustRaptor/docs/iron-coast.md). diff --git a/Cargo.lock b/RustRaptor/Cargo.lock similarity index 100% rename from Cargo.lock rename to RustRaptor/Cargo.lock diff --git a/Cargo.toml b/RustRaptor/Cargo.toml similarity index 100% rename from Cargo.toml rename to RustRaptor/Cargo.toml diff --git a/RustRaptor/LICENSE b/RustRaptor/LICENSE new file mode 100644 index 00000000..d159169d --- /dev/null +++ b/RustRaptor/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + 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; either version 2 of the License, or + (at your option) any later version. + + 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. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/RustRaptor/PLAN.md b/RustRaptor/PLAN.md new file mode 100644 index 00000000..b1549149 --- /dev/null +++ b/RustRaptor/PLAN.md @@ -0,0 +1,30 @@ +# Rust rewrite milestones + +The independent Rust engine is the default launcher on branch `codex/rust-rewrite`. Its source, mods, schemas, tests and documentation live in `RustRaptor/`. The retained C++ checkout is optional reference material. Original catalog/reference assets remain ignored; authored mod images and sounds are tracked in Git. + +The registered game has three sectors with nine waves each. Archive slots are asset containers, not five independent campaigns. + +## Implemented + +- Checked asset decoders, typed namespaced registries and original content registration. +- Independent integer simulation at 70/3 Hz; deterministic RNG and SDL2 input/interpolated indexed rendering. +- Original / Scale2x / Scale3x / Scale4x modes, joined terrain filtering, sharp bitmap text and separate derived cache. +- Rust MUS, GENMIDI FM synthesis and digital DSP mixing, with native PCM comparisons. +- Menus, shop, equipment, mission selection, pilot slots, atomic saves, read-only legacy import and original AGX cinematics. +- Local mods, dependencies/conflicts, explicit overrides/priority/hashes, bounded deterministic Rhai hooks and transactional commands. +- In-game enable/disable and pending changes, save diagnostics, schemas, validation CLI and scripted Aurora example. +- Optional source-hashed HD loader/setting; one accepted player-frame proof with original fallback. +- C++/Rust replay recording and comparison tools, synthetic tests and local-original-data checks. + +## Validation and remaining work + +1. **Playable first mission:** fresh pilots win through the boss at difficulties 1 and 2 using deterministic input and predictive steering. The complete 1,889-step original demo matches all captured native gameplay fields. Wider ordinary playtesting remains useful. +2. **Complete game coverage:** all 27 waves complete at each of four difficulties under an assisted fixture; all original weapons, training/campaign progression, legacy import and cinematics have checks. These fixtures do not establish exhaustive native parity or every menu/save route. +3. **Mod API version 1:** dependencies, overrides, script limits/errors, atomic enable/disable and incompatible saves have regressions. The bundled mission, weapons and enemies validate and have controlled playthroughs. +4. **Visual treatment:** complete sampled sprite sequences, palette shadows and joined terrain pass local checks; the desktop renderer has SD/HD 1080p measurements. The base-game HD remaster is partial. Rust is the default at the user's request; finishing every reference comparison is not a prerequisite to playing it. + +See [validation instructions](docs/rust-rewrite.md) and [HD coverage](docs/hd-art.md) for the evidence and its limits. Online mod distribution, an editor, total conversions, external MIDI/SoundFonts and additional desktop targets remain outside scope. + +## New content + +Separate agents built Iron Coast mission 1, three individual weapon mods and three individual enemy mods using the public API. Each package ships local SD/HD artwork and procedurally generated sound effects, with definitions, scripts, images and sounds tracked together in Git. The mission dependencies and two controlled victories are documented in [Iron Coast](docs/iron-coast.md). diff --git a/RustRaptor/README.md b/RustRaptor/README.md new file mode 100644 index 00000000..886ee427 --- /dev/null +++ b/RustRaptor/README.md @@ -0,0 +1,77 @@ +# RustRaptor + +The independent Linux Rust game and its complete local mod packages live here. +SDL2 handles platform services; gameplay, asset decoding, rendering, audio, +saving and mod scripting run in Rust. + +From the repository root: + +```sh +./play.sh +``` + +Or from this folder: + +```sh +./run.sh +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 **Mods** screen discovers the bundled `mods/` packages when no user mods +folder exists. Enable the Iron Coast mission package to select its six +dependencies automatically, then apply at the main menu. The three weapons can also be enabled individually. +For a user mods folder, pass `--mods DIR` or create +`$XDG_DATA_HOME/RaptorReplica/mods/`. Saves and configuration use separate XDG +`RaptorReplica` directories, with read-only legacy import. + +Enter the **Hangar** to fly, choose missions, visit the supply room, manage +equipment or **Save pilot**. New pilots have editable names, callsigns and ID +pictures and difficulty; existing pilots can edit their ID from the hangar. +Registration starts with 10,000 credits and 75 energy, as in the original. +The supply room +shows an equipment image, description, price, resale value, quantity and +whether it fires automatically, is selectable, is consumed or works passively. + +Default keyboard controls are arrow keys to move, **Ctrl** to fire, +**Space** to drop a bomb, **Alt or Shift** to cycle equipment, and **Z** to +use a selected consumable. Custom bindings remain configurable in Options. +Existing untouched Rust default bindings migrate to these controls. + +| Folder | Contents | +| --- | --- | +| `src/` | Engine, SDL host and validation CLI | +| `mods/` | Aurora example, Iron Coast mission, three weapons and three enemies | +| `schemas/` | Version 1 mod API schemas | +| `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 | +| `target/` | Local Cargo builds, excluded from Git | + +All bundled mod definitions, scripts, SD/HD PNGs, WAVs, generation prompts and +recipes are tracked in Git. Original data and local reference pixels are excluded. +The engine supports original and 2×/3×/4× crisp scaling, plus an optional HD +artwork setting. Bundled mods have SD/HD artwork; the base-game HD proof covers +one player frame, with crisp originals supplying the other frames. + +First-mission wins at difficulties 1 and 2, a complete 1,889-step native demo +comparison, assisted coverage of all 27 missions at four difficulties, and +sample-exact audio comparisons have passed. These checks cover their documented +cases; wider playtesting and complete base-game HD coverage remain work in progress. + +- [Build and validation](docs/rust-rewrite.md) +- [Mod API](docs/modding-api.md) +- [Iron Coast packages](docs/iron-coast.md) +- [Weapon prices and balance](docs/weapon-balance.md) +- [Sprite and shadow validation](docs/visual-validation.md) +- [Sound playback validation](docs/sound-validation.md) +- [Display measurements](docs/performance.md) +- [First mission](docs/first-mission-validation.md) +- [Campaign coverage](docs/campaign-validation.md) +- [Optional HD artwork](docs/hd-art.md) +- [Milestones](PLAN.md) + +Code is GPL-2.0-or-later; the vendored Rust FM emulator is LGPL-2.1-or-later. diff --git a/build.rs b/RustRaptor/build.rs similarity index 100% rename from build.rs rename to RustRaptor/build.rs diff --git a/docs/campaign-validation.md b/RustRaptor/docs/campaign-validation.md similarity index 86% rename from docs/campaign-validation.md rename to RustRaptor/docs/campaign-validation.md index 768aa396..698ba409 100644 --- a/docs/campaign-validation.md +++ b/RustRaptor/docs/campaign-validation.md @@ -26,17 +26,17 @@ reported outcome or its 12,000-step inspection limit. The final run visited every spawn record, observed every expected boss instance and finished with no remaining enemies. Its local CSV is -`.reference/campaign-validation.csv` (SHA-256 -`8702d190a039239de154e09ebbb5972f6d7ffe07475d7d47b199b53efb3a811a`). +`../.reference/campaign-validation-final.csv` (SHA-256 +`c4e1fc0ca1199efd2a9e72ceb7ebbe3d71325b517cb9829995138ead99486901`). | Difficulty | Completed waves | Steps per wave | Enabled spawns | Boss instances seen | Nonpositive last health | Positive last health | | --- | ---: | --- | ---: | ---: | ---: | ---: | | 0 | 27 | 4,601–4,873 | 3,751 | 88 | 53 | 35 | | 1 | 27 | 4,607–4,873 | 3,751 | 88 | 53 | 35 | -| 2 | 27 | 4,651–5,079 | 6,259 | 88 | 36 | 52 | -| 3 | 27 | 4,650–5,082 | 8,668 | 88 | 37 | 51 | +| 2 | 27 | 4,651–5,079 | 6,259 | 88 | 37 | 51 | +| 3 | 27 | 4,650–5,082 | 8,668 | 88 | 36 | 52 | -The 108 runs cover 513,002 simulation steps, 22,429 enabled spawns and 352 boss +The 108 runs cover 513,003 simulation steps, 22,429 enabled spawns and 352 boss appearances. Boss retirement counts classify the health observed immediately before removal: 179 nonpositive and 173 positive. Positive health commonly indicates a boss leaving the screen; damage and removal within the same step @@ -52,10 +52,10 @@ training's four-wave route, each normal 27-wave route, difficulty promotion, campaign resets and the training reward. They call the progression API directly and bypass combat; end cinematics and menu transitions need separate checks. -Run the four original-data integration tests from the project directory: +Run the four original-data integration tests from the `RustRaptor/` directory: ```sh -RAPTOR_CAMPAIGN_REPORT=.reference/campaign-validation.csv \ +RAPTOR_CAMPAIGN_REPORT=../.reference/campaign-validation-final.csv \ cargo test --release --no-default-features --test campaigns -- --ignored --nocapture ``` @@ -72,26 +72,26 @@ cargo build --release --no-default-features --lib rustc --edition=2024 tools/campaign_check.rs \ --extern raptor_replica=target/release/deps/libraptor_replica-HASH.rlib \ --extern anyhow=target/release/deps/libanyhow-HASH.rlib \ - -L dependency=target/release/deps -o .reference/campaign-check -.reference/campaign-check data + -L dependency=target/release/deps -o ../.reference/campaign-check +../.reference/campaign-check ../data ``` Its optional second argument selects one difficulty, for example -`.reference/campaign-check data 3`. +`../.reference/campaign-check ../data 3`. ## Reproduce the complete demo comparison Rebuild the retained C++ source before capturing a reference. The local debug -build used `.reference/cpp-rng-build`; use the available CMake executable if it +build used `../.reference/cpp-rng-build`; use the available CMake executable if it is outside `PATH`. Prepare an isolated legacy profile with `raptorsetup`, or -copy an existing `SETUP.INI` into `.reference/full-demo-profile/Raptor/` before +copy an existing `SETUP.INI` into `../.reference/full-demo-profile/Raptor/` before running the following commands. The original executable requires `data/` as its working directory. A complete reference takes about 81 seconds at 70/3 Hz. ```sh -cmake --build .reference/cpp-rng-build --parallel 4 +cmake --build ../.reference/cpp-rng-build --parallel 4 ( - cd data + cd ../data XDG_DATA_HOME="$PWD/../.reference/full-demo-profile" \ SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \ RAPTOR_REFERENCE_TRACE="$PWD/../.reference/cpp-demo1-full.jsonl" \ @@ -99,9 +99,9 @@ cmake --build .reference/cpp-rng-build --parallel 4 ../.reference/cpp-rng-build/bin/raptor PLAY demos/DEMO1G1_REC ) cargo run --no-default-features -- \ - --replay DEMO1G1_REC --ticks 1889 --trace .reference/rust-demo1-full.jsonl + --replay DEMO1G1_REC --ticks 1889 --trace ../.reference/rust-demo1-full.jsonl python3 tools/compare_replays.py \ - .reference/cpp-demo1-full.jsonl .reference/rust-demo1-full.jsonl + ../.reference/cpp-demo1-full.jsonl ../.reference/rust-demo1-full.jsonl ``` The comparator reports `Identical gameplay snapshots across 1889 simulation diff --git a/docs/first-mission-validation.md b/RustRaptor/docs/first-mission-validation.md similarity index 96% rename from docs/first-mission-validation.md rename to RustRaptor/docs/first-mission-validation.md index b6b64354..c0f17413 100644 --- a/docs/first-mission-validation.md +++ b/RustRaptor/docs/first-mission-validation.md @@ -12,9 +12,9 @@ Run it with: RAPTOR_TEST_DATA=/absolute/path/to/data cargo test --release --no-default-features --test first_mission -- --ignored --nocapture ``` -Without `RAPTOR_TEST_DATA`, it reads this checkout's `data/` directory. The +Without `RAPTOR_TEST_DATA`, it reads this checkout's `../data/` directory. The test is ignored in the default suite because original game data is not shipped -in Git. It writes local results to `.reference/`, which is also ignored. +in Git. It writes local results to `../.reference/`, which is also ignored. ## Recorded results @@ -75,7 +75,7 @@ difficulty, input-device usability, graphics quality, or audio accuracy. For each difficulty, the test generates: -- `.reference/first-mission-fresh-difficulty-N.dem`: the original 12-byte demo +- `../.reference/first-mission-fresh-difficulty-N.dem`: the original 12-byte demo record layout, with a sector/wave header, button bytes, observed player position and bank. It includes the complete boss encounter and ends at the flyby cue, before stats-stage forced departure movement: 4,603 and 4,723 diff --git a/docs/hd-art.md b/RustRaptor/docs/hd-art.md similarity index 74% rename from docs/hd-art.md rename to RustRaptor/docs/hd-art.md index b9303579..7467d944 100644 --- a/docs/hd-art.md +++ b/RustRaptor/docs/hd-art.md @@ -1,17 +1,17 @@ # Optional HD artwork -The Rust Options screen includes **HD art**. It starts disabled. Enable it to use reviewed artwork from `data/hd/hd.json`; each missing or rejected frame keeps the selected original / 2x / 3x / 4x crisp treatment. The scale setting still determines the indexed rendering resolution, and the HD toggle can be changed while paused. HD art is presentation only: simulation, collision sizes, banking positions and save content IDs stay unchanged. +The Rust Options screen includes **HD art**. It starts disabled. Enable it to use reviewed artwork from `../data/hd/hd.json`; each missing or rejected frame keeps the selected original / 2x / 3x / 4x crisp treatment. The scale setting still determines the indexed rendering resolution, and the HD toggle can be changed while paused. HD art is presentation only: simulation, collision sizes, banking positions and save content IDs stay unchanged. -The current local proof covers **the light player ship's straight banking frame only** (`0x1011d`, frame 3 of 7). The other six frames, the dark-sector ship, enemies, projectiles and terrain retain original art. Switching into a bank therefore changes visual style. This proof is suitable for evaluating the look; it is not a finished remaster. Original and generated game pixels are excluded from Git. +The current local base-game proof covers **the light player ship's straight banking frame only** (`0x1011d`, frame 3 of 7). The other six frames, the dark-sector ship, enemies, projectiles and terrain retain original art. Switching into a bank therefore changes visual style. This proof is suitable for evaluating the look; it is not a finished remaster. Original pixels and this base-game proof remain outside Git. The separate authored mod packages include their generated SD/HD PNGs, sounds and provenance in Git. The source was decoded with `tools/hd_art_source.py`, inspected, and passed to the **built-in image_gen tool** using the imagegen skill. The optional proof and its exact generation prompt are saved locally at: -- `data/hd/light-player-straight-hd-v1.png` -- `data/hd/prompts/light-player-straight-v1.txt` -- `data/hd/provenance.json` -- `data/hd/source/light-player-straight.png` +- `../data/hd/light-player-straight-hd-v1.png` +- `../data/hd/prompts/light-player-straight-v1.txt` +- `../data/hd/provenance.json` +- `../data/hd/source/light-player-straight.png` -The PNG has real alpha transparency, is 1254x1254, and was copied unchanged from the tool's output. At the original 32x32 logical resolution, its visible alpha footprint overlaps the source by 92.1% intersection-over-union; its bounds are `(3,0)..(29,31)` against `(2,0)..(29,31)` for the original. The nose, wing layout, silver/gunmetal body and dark red accents remain recognizable, but panel detail and some contours differ. Further artistic review and a complete, consistent banking sequence are required before enabling HD art by default. An additional extreme-bank attempt is saved as `data/hd/light-player-bank-0-hd-v1.png` with `prompts/light-player-bank-0-v1.txt`, but is excluded from the active pack because its footprint overlap is only 62.2% and its engine ends are shortened. The original extreme-bank sprite continues to render. +The PNG has real alpha transparency, is 1254x1254, and was copied unchanged from the tool's output. At the original 32x32 logical resolution, its visible alpha footprint overlaps the source by 92.1% intersection-over-union; its bounds are `(3,0)..(29,31)` against `(2,0)..(29,31)` for the original. The nose, wing layout, silver/gunmetal body and dark red accents remain recognizable, but panel detail and some contours differ. Further artistic review and a complete, consistent banking sequence are required before enabling HD art by default. An additional extreme-bank attempt is saved as `../data/hd/light-player-bank-0-hd-v1.png` with `prompts/light-player-bank-0-v1.txt`, but is excluded from the active pack because its footprint overlap is only 62.2% and its engine ends are shortened. The original extreme-bank sprite continues to render. ## Pack format @@ -54,7 +54,7 @@ Use the existing catalog decoder to prepare a transparent reference without redr ```sh python3 tools/hd_art_source.py --asset 1011d \ - --output data/hd/source/light-player-straight.png --preview-scale 16 + --output ../data/hd/source/light-player-straight.png --preview-scale 16 ``` Inspect every source frame before generating its replacement. Request the same orthographic camera, silhouette, canvas placement, orientation, material colors and lighting for every frame. Preserve the generated alpha. Do not substitute rotations of one frame for the original bank poses: compare the complete sequence at its logical size and at the intended display resolution. Verify that the nose, engine exits, wing extremes and animation pivots remain aligned and that no clipping or background halos appear. diff --git a/docs/iron-coast.md b/RustRaptor/docs/iron-coast.md similarity index 79% rename from docs/iron-coast.md rename to RustRaptor/docs/iron-coast.md index da75ab43..29412db9 100644 --- a/docs/iron-coast.md +++ b/RustRaptor/docs/iron-coast.md @@ -1,9 +1,11 @@ # Iron Coast content packages Iron Coast is a local campaign assembled from seven independently toggleable -mods. Enable all six dependencies before enabling the campaign package. The -Mods manager validates versions and dependency order and applies the change -at the main menu. The new weapons can also be used in the original campaigns; +mods. Enabling the mission package checks and selects its six installed +dependencies automatically. Disabling it also deselects unused dependencies +after a confirmation listing the affected packages. Dependencies still needed +by another enabled mod remain selected. The Mods manager validates versions +and dependency order and applies the change at the main menu. The new weapons can also be used in the original campaigns; the enemy packages can be referenced from other missions. | Package | Content | @@ -29,15 +31,16 @@ turrets and the command ship to finish. The boss stays in the playfield until killed; waiting out the mission duration cannot produce victory. When inventory space allows, the mission grants a trial Pulse Cannon, which -fires alongside forward guns, and three field repairs. Granted equipment -remains with the pilot. A full inventory can still start; new grants are +fires alongside forward guns, and three field repairs. The cannon is a mission-only loan and is returned before a successful flight +is saved; a cannon already owned by the pilot remains untouched. A full inventory can still start; new grants are skipped while existing equipment is preserved. Cycle equipment to **Iron Coast Field Repair** and press the special button to restore 25 shield per repair. Depots drop extra repairs. Victory pays 5,000 credits plus ordinary enemy rewards. The central turrets and boss patrol overlap the starter-gun firing corridor; the mission is intended to remain playable without the trial weapon. -Script objectives are namespaced and saved with the pilot: +Script objectives are namespaced and tracked for the current flight. Script +state and completed mission IDs are saved with the pilot: | Objective | Meaning | | --- | --- | @@ -47,7 +50,7 @@ Script objectives are namespaced and saved with the pilot: | `iron_coast_boss:defeated` | Command ships destroyed | | `iron_coast_mission_1:depots_opened` | Supply depots destroyed | | `iron_coast_mission_1:complete` | All required targets destroyed | -| `iron_coast_mission_1:trial_issued` | Trial weapon was granted or replenished | +| `iron_coast_mission_1:trial_issued` | A loan or an already owned cannon is available | ## Art and audio @@ -88,8 +91,8 @@ required locally to play the game and remains excluded from version control. To rebuild the mission assets from their included generated masters, run: ```sh -python3 examples/mods/iron_coast_mission_1/build_assets.py --palette data/misc/PALETTE_DAT -cargo run --no-default-features -- --mods examples/mods --validate-mods iron_coast_scout,iron_coast_turret,iron_coast_boss,pulse_cannon,seeker_swarm,ion_lance,iron_coast_mission_1 +python3 mods/iron_coast_mission_1/build_assets.py --palette ../data/misc/PALETTE_DAT +cargo run --no-default-features -- --mods mods --validate-mods iron_coast_scout,iron_coast_turret,iron_coast_boss,pulse_cannon,seeker_swarm,ion_lance,iron_coast_mission_1 ``` ## Acceptance checks @@ -102,10 +105,12 @@ and the trial Pulse Cannon. These tests use no original images and replace the generated assets with synthetic fixtures. Local package validation additionally checks the actual generated SD/HD images and WAV files. -Release acceptance passes all six tests in **1.43 seconds**. Synthetic -seven-package activation takes 0.179–0.290 seconds including fixture creation -and PNG/WAV validation. Actual seven-package validation, including the generated -SD/HD artwork and WAV files, passes in **2.234 seconds** with zero overrides. +Release acceptance passes all eight tests in **1.20 seconds**. The tests also +check loan cleanup after victory, retention of an owned cannon, and starting +with a full inventory including stacked super-shields. Synthetic seven-package +activation takes 0.173–0.294 seconds including fixture creation and PNG/WAV +validation. Local package validation also checks the generated SD/HD artwork +and WAV files, with zero overrides. The two playable runs complete as follows: | Loadout | Completion step | Final shield | Credits | @@ -122,11 +127,7 @@ enemy damage commands or victory-objective mutations. They establish that the actual waves and boss can be completed under this controlled input, not that every normal-player route or loadout has been balanced. -The default debug suite took 34.15 seconds for the earlier five-test version; -the release result shows that its slowdown comes mainly from unoptimized -simulation, serialization and scripting. PNG color memoization reduced the -previous multi-minute asset validation overhead. Release measurements above -exclude compilation. +Release measurements exclude compilation. Visual approval remains provisional: sprite and terrain previews were inspected for readability, canvas alignment and transparency, with numeric diff --git a/docs/modding-api.md b/RustRaptor/docs/modding-api.md similarity index 80% rename from docs/modding-api.md rename to RustRaptor/docs/modding-api.md index 046564eb..34f08a68 100644 --- a/docs/modding-api.md +++ b/RustRaptor/docs/modding-api.md @@ -4,24 +4,24 @@ This is an experimental API for the Rust engine. It provides local content packa ## Try the example -`examples/mods/aurora/` adds two projectiles, a pulse cannon, a repair cell, two enemies, a mission and a campaign. The weapon adds angled shots in Rhai; enemy movement, item rewards and mission objectives also use scripts. Its artwork references asset IDs in the locally extracted original catalog. The example contains no original or generated game artwork. +`mods/aurora/` adds two projectiles, a pulse cannon, a repair cell, two enemies, a mission and a campaign. The weapon adds angled shots in Rhai; enemy movement, item rewards and mission objectives also use scripts. Its artwork references asset IDs in the locally extracted original catalog. The example contains no original or generated game artwork. Validate the package against your original game data: ```sh -cargo run --no-default-features -- --data data --mods examples/mods --validate-mods aurora +cargo run --no-default-features -- --data ../data --mods mods --validate-mods aurora ``` Run its mission without a window: ```sh -cargo run --no-default-features -- --data data --mods examples/mods \ +cargo run --no-default-features -- --data ../data --mods mods \ --enabled aurora --headless aurora:field_trials --ticks 700 ``` -For the in-game manager, copy `examples/mods/aurora` into the `mods/` directory under `$XDG_DATA_HOME/RaptorReplica` (default `~/.local/share/RaptorReplica/mods/`), or start the Rust engine with `--mods examples/mods`. Open **Mods**, toggle Aurora, then select **Apply selections**. Select its mission from the mission menu. Existing pilots can add mods while preserving required versions, hashes and relative load order; the next save records the expanded active set. Disabling, changing or reordering required mods blocks loading with details. Use another pilot for an incompatible set. +The manager discovers the bundled `mods/` folder by default when no user mods folder exists. You can also copy `mods/aurora` into the `mods/` directory under `$XDG_DATA_HOME/RaptorReplica` (default `~/.local/share/RaptorReplica/mods/`), or start the Rust engine with `--mods mods`. Open **Mods**, toggle Aurora, then select **Apply selections**. Select its mission from the mission menu. Existing pilots can add mods while preserving required versions, hashes and relative load order; the next save records the expanded active set. Disabling, changing or reordering required mods blocks loading with details. Use another pilot for an incompatible set. -In the Mods screen, Enter toggles a package or activates the selected action. Rescan after editing a package on disk. Load dependencies before dependents; independent packages retain your chosen priority. Later explicit overrides win. Changes made through the pause menu remain pending until the main menu. Candidate registries and scripts are validated before the active set changes. +In the Mods screen, Enter toggles a package or activates the selected action. Enabling a package immediately checks versions, API compatibility, cycles and conflicts, then selects its installed dependencies recursively. A failed check leaves the selection unchanged. Disabling includes dependent mods and dependencies that become unused, preserving dependencies required by other remaining packages. A single-package disable toggles immediately; changes affecting multiple packages show an **Are you sure?** popup listing every affected mod, with Cancel selected initially. Cancel or Back preserves the pending selection. Rescan after editing a package on disk. Dependencies load before dependents; independent packages retain your chosen priority. Later explicit overrides win. Changes made through the pause menu remain pending until the main menu. Candidate registries and scripts are validated before the active set changes. ## Package layout and manifest @@ -50,7 +50,7 @@ conflicts = ["alternate_aurora"] common_tools = "^1.2" ``` -IDs use lowercase ASCII letters, digits, `_` and `-`, with a maximum of 64 bytes. `base` is reserved. Package versions and dependency requirements use semantic version syntax. Dependencies must be explicitly enabled; the loader will not quietly enable or install them. Cycles, missing packages, unsupported API versions, incompatible dependency versions and declared conflicts reject activation. Two installed folders claiming the same ID make that ID unusable; rename or remove one folder, then rescan. +IDs use lowercase ASCII letters, digits, `_` and `-`, with a maximum of 64 bytes. `base` is reserved. Package versions and dependency requirements use semantic version syntax. The in-game manager selects compatible installed dependencies when enabling a package. It never downloads or installs missing packages. The CLI still requires a complete explicit `--enabled` set. Cycles, missing packages, unsupported API versions, incompatible dependency versions and declared conflicts reject activation. Two installed folders claiming the same ID make that ID unusable; rename or remove one folder, then rescan. All package paths are relative. Absolute paths, parent traversal, symlinks and special files are rejected. Packages are limited to 10,000 files, 16 directory levels, 64 MiB per file and 256 MiB in total. Manifests are limited to 64 KiB; display names to 128 bytes; descriptions to 4 KiB; script declarations to 64, with a 1 MiB limit per source file. Every file, including artwork and documentation, contributes to the package SHA-256 hash. A package changed after discovery requires rescanning before it can be applied. @@ -58,6 +58,14 @@ All package paths are relative. Absolute paths, parent traversal, symlinks and s `content.json` has optional `weapons`, `items`, `enemies`, `missions`, `campaigns`, `sounds` and `overrides` arrays. Unknown fields are rejected. IDs have the form `namespace:name`, for example `aurora:pulse_cannon`. IDs are stable save references and unique across content types. Keep item and projectile IDs distinct: the item `aurora:pulse_cannon` refers to the weapon/projectile `aurora:pulse_projectile` through its `weapon` property. +Items may include an optional `description` of up to 4096 UTF-8 bytes for shop information. The shop displays the description, artwork, effective target types, firing mode, price, ownership and inventory limits. + +The item price buys `initial_count` units, up to its capacity. Selling an +ordinary item removes one unit and returns half its unit purchase price, +rounded down. Original energy refills and super-shield modules retain their +half-price sale per refill/module. This prevents multi-unit mod packs from +creating credits through repeated buying and selling. + New definitions must belong to the package namespace. An existing definition may be replaced only when its ID appears in `overrides` and the replacement retains the same content type. Overrides are complete replacements, not field merges. An override targeting a missing ID, a replacement without explicit permission, an override without its replacement, or duplicate IDs within a pack rejects the whole pack. The manager exposes the override history and its winning owner. ```json @@ -152,6 +160,15 @@ Commands apply only after a callback returns and passes validation. If a callbac ## Commands +The 20-slot inventory limit counts physical super-shield modules separately. +`player.super_shield_reserves` lists the stored charge of reserve modules; +`player.inventory["base:super_shield"]` is the equipped module's charge. +For the built-in `base:energy` and `base:super_shield`, `give_item` and +`consume_item` change charge units, not the number of modules. Zero-charge +shield entries remain valid. Other items are removed when their quantity +reaches zero, releasing their slot and selecting another available special +item when needed. + Use the `op` field shown below. All fields listed are required unless stated otherwise. Entity zero identifies the player for damage; movement commands currently address enemies. | `op` | Fields | Effect | diff --git a/RustRaptor/docs/performance.md b/RustRaptor/docs/performance.md new file mode 100644 index 00000000..d74d45e2 --- /dev/null +++ b/RustRaptor/docs/performance.md @@ -0,0 +1,41 @@ +# Desktop display measurements + +Local release measurements on 2026-10-10 used SDL's OpenGL renderer at +1920×1080 fullscreen output, aspect correction, Scale4, no scanlines, and all seven Iron Coast +packages. They include normal terrain, sprites, indexed shadows, effects and +mod scripting. They exclude Cargo compilation. + +| Mode | Startup | Render work median | Render work 95th percentile | Frame median | Frame 95th percentile | +| --- | ---: | ---: | ---: | ---: | ---: | +| SD artwork | 2,345.82 ms | 7.40 ms | 8.76 ms | 7.52 ms | 8.88 ms | +| HD artwork | 2,375.56 ms | 8.67 ms | 14.66 ms | 8.79 ms | 14.78 ms | + +Each run sampled 174 frames from a 180-frame flight, excluding five warmup +frames and the screenshot frame. Work time excludes the presentation call; +frame time includes it. Startup covers desktop-host construction and SDL setup; +the preceding CLI catalog registration is excluded. The explicit one-millisecond smoke-loop sleep is +outside both measurements. These short runs establish local performance for +this scene and machine, not worst-case performance across every boss or device. +The fixed 70/3 Hz simulation advances independently of display timing. + +From `RustRaptor/`, prepare two isolated profile configurations with +`width = 1920`, `height = 1080`, `fullscreen = true`, `scale = 4`, `scanlines = false`, and +`hd_mode = false` or `true`. Enable the mission's six dependencies and mission +package in `enabled_mods`; see [Iron Coast](iron-coast.md). Run: + +```sh +./run.sh --profile-root ../.reference/enemy-sd-profile \ + --preview-mission iron_coast_mission_1:mission_01 \ + --smoke-frames 180 --benchmark \ + --screenshot ../.reference/rustraptor-sd-1080-final.png +./run.sh --profile-root ../.reference/enemy-hd-profile \ + --preview-mission iron_coast_mission_1:mission_01 \ + --smoke-frames 180 --benchmark \ + --screenshot ../.reference/rustraptor-hd-1080-final.png +``` + +Both captures were checked to be exactly 1920×1080. The benchmark reports the +actual SDL output size, since a window manager can constrain a requested window. +Use a real SDL display. Dummy-video screenshots verify raster composition but +cannot measure GPU presentation. Frame-limited runs do not save pilots or +settings. Profiles, caches and screenshots remain ignored local files. diff --git a/RustRaptor/docs/rust-rewrite.md b/RustRaptor/docs/rust-rewrite.md new file mode 100644 index 00000000..8f8fa454 --- /dev/null +++ b/RustRaptor/docs/rust-rewrite.md @@ -0,0 +1,101 @@ +# Rust engine and validation + +Rust is the default engine. The Rust binary never calls or embeds the C++ engine. SDL2 provides window/input/controller/audio services. Simulation and the validation CLI build without SDL2. The retained C++ checkout is optional reference material. + +| Module | Responsibility | +| --- | --- | +| assets | Checked catalog, palettes, raw/span pictures, libraries, maps and demo records. | +| content, legacy_tables | Typed registries and generated original numeric mappings. | +| simulation | Rational clock, integer motion, shared RNG, collision/weapon/enemy/terrain rules, script transactions. | +| render, hd | Indexed canvas, ScaleNx, caches, PNG/palette conversion and optional source-hashed HD art. | +| audio, sound | Original MUS/GENMIDI FM and digital DSP behavior; package-local PCM WAV decoding. | +| cinematics | Checked AGX delta frames and movie timing. | +| persistence | TOML settings, versioned JSON pilots, atomic writes and read-only legacy import. | +| modding | Packages, dependency ordering, overrides, bounded Rhai callbacks/state and save pins. | +| desktop | SDL2 services, UI, shop/equipment, mod manager, settings and interpolation. | + +## Build and run + +Rust 1.88+, Cargo and SDL2 development libraries are required. A local SDL2 installation in `~/.local/lib` is supported. Commands below run from `RustRaptor/`. The launcher sets the library path and uses a release build: + +```sh +./run.sh +./run.sh --mods mods +``` + +For development and tests: + +```sh +export LD_LIBRARY_PATH="$HOME/.local/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +cargo test --locked +cargo test --locked --no-default-features +RAPTOR_TEST_DATA="$PWD/../data" cargo test --locked +RAPTOR_TEST_DATA="$PWD/../data" cargo test --locked -- --ignored +``` + +Portable fixtures use synthetic assets and the bundled authored mod art/audio. Original integration checks are opt-in and local. Native audio comparisons compile a small retained-source harness with g++ and SDL2 headers; pure Rust playback itself uses no C++ runtime. + +## Profiles and settings + +Rust uses `$XDG_DATA_HOME/RaptorReplica/pilot-NNNN.json` and `mods/`, `$XDG_CONFIG_HOME/RaptorReplica/config.toml`, and `$XDG_CACHE_HOME/RaptorReplica/` for derived assets. Normal XDG defaults apply. Pilots record active package versions/hashes and script state/RNG. Missing or changed requirements block loading, without deleting equipment or replacing the save. + +Legacy `~/.local/share/Raptor/SETUP.INI` and pilot files are imported read-only. All difficulties, waves, portrait and training metadata are retained. Rust writes only its separate versioned files. Explicit CLI import output cannot be the original file. + +Options include scaling, scanlines, aspect correction, fullscreen/borderless, input bindings/controller, volumes and optional HD art. New/load pilot controls keep incompatible save slots separate. The Mods screen applies validated selections at the main menu; selections made during flight remain pending until returning there. + +The main menu leads to the Hangar, new/load/import pilots, options, mods and +extras. The Hangar keeps flying, mission selection, the supply room, inventory, +**Save pilot**, pilot ID editing and exit visible together. Saving writes the +pilot atomically and shows the callsign and slot. Completed missions and +equipment purchases also save automatically. Saves contain hangar/progression +state; an in-flight save does not create a mid-mission checkpoint. +New pilot registration selects a name, callsign, ID picture and difficulty, +and starts with the original 10,000 credits and 75 energy. Imported ID pictures +retain their native catalog identity. + +Keyboard defaults are arrows for movement, Ctrl for firing, Space for bombs, +Alt or Shift for cycling equipment, and Z for selected consumables. Selecting +a weapon makes the fire control fire it alongside the forward guns. Settings +that retain the old complete default cycle/bomb/special binding combination +migrate on load; custom combinations are preserved. + +## Local asset and platform checks + +```sh +cargo run --locked --no-default-features -- --data ../data --verify-assets +cargo run --locked --no-default-features -- --data ../data --mods mods --validate-mods aurora +cargo run --locked --no-default-features -- --data ../data --mods mods \ + --enabled aurora --headless aurora:field_trials --ticks 700 +SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy cargo run --locked -- \ + --data ../data --profile-root ../.reference/smoke --smoke-frames 5 \ + --screenshot ../.reference/menu.png +``` + +Use real SDL video for interactive/display-performance checks. Dummy screenshots verify raster placement, not GPU/vsync performance. `--preview-mission ID` starts a mission; `--preview-cinematic intro|landing|death|end1|end2|end3` previews movies. Frame-limited runs do not save pilots/settings; cache stays within the selected profile. + +## Controlled reference replays + +This optional comparison workflow uses the retained C++ source. Rebuild it before recording. Trace output is opt-in during legacy demo playback and records complete updates after palette/stat processing, which also consumes the shared RNG. + +```sh +mkdir -p ../.reference +cmake --build ../raptor/build --target raptor --parallel 8 +(cd ../data && env SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \ + XDG_DATA_HOME="$PWD/../.reference/legacy-profile" \ + RAPTOR_REFERENCE_TRACE="$PWD/../.reference/cpp-demo.jsonl" \ + RAPTOR_REFERENCE_TICKS=1889 \ + ../raptor/build/bin/raptor PLAY "$PWD/demos/DEMO1G1_REC") +cargo run --locked --no-default-features -- --data ../data \ + --replay DEMO1G1_REC --ticks 1889 --trace ../.reference/rust-demo.jsonl +python3 tools/compare_replays.py ../.reference/cpp-demo.jsonl ../.reference/rust-demo.jsonl +``` + +All 1,889 available steps of DEMO1G1_REC match the captured fields: RNG, player coordinates/bank/shields/credits, ordered enemy position/health/animation/path state and ordered projectile assets/positions. The comparator fails at first divergence or unequal lengths. This is a subset of full state; inventory, effects, objectives and internal AI counters are outside this trace. That demo starts sector 1 wave 3 and ends before its boss. See the separate [first-mission wins](first-mission-validation.md), [108 campaign runs](campaign-validation.md) and [departure/death checks](transition-validation.md). Recordings stay ignored in `../.reference/`. + +## Audio and remaining acceptance + +The Rust FM/DSP implementation has synthetic sample-by-sample native comparisons and a four-second original intro-music comparison at 44.1kHz. The checked sound bank also dispatches original and mod cues through the SDL callback; real-device output checks are documented in [sound validation](sound-validation.md). These establish those cases, not every track/input/output rate. The Rust core retains the original envelope/noise semantics and LGPL source provenance. + +The campaign checks cover all waves and difficulties with assistance, and first-mission tests win without replenishing pilot health or editing enemy state. Wider ordinary playtesting and more complete native state comparisons remain useful. Original cinematics decode/play locally; exact fade/transition/custom-palette fidelity has limited comparison coverage. Some menu layouts differ. Base-game HD coverage is partial; bundled mods supply SD/HD art. See [display measurements](performance.md). + +Scale2x/3x follows the [published algorithm](https://www.scale2x.it/algorithm); Scale4x applies Scale2x twice. WAV parsing follows the [RIFF chunk format](https://learn.microsoft.com/en-us/windows/win32/xaudio2/resource-interchange-file-format--riff-). Original catalog/reference assets remain outside version control. Authored mod artwork and sounds are tracked. diff --git a/RustRaptor/docs/sound-validation.md b/RustRaptor/docs/sound-validation.md new file mode 100644 index 00000000..e45a0f24 --- /dev/null +++ b/RustRaptor/docs/sound-validation.md @@ -0,0 +1,52 @@ +# Sound playback validation + +The Rust engine plays original digital effects, MUS music through its Rust FM +synthesizer, and package-local WAV cues. SDL2 provides the output device. + +Original sound-event IDs identify an empty catalog marker. The actual digital +patch is four catalog entries later, matching the original setup routine. +The sound bank resolves and checks that patch, preloads the 26 original +digital assets, and reports missing or invalid cues. Mod WAVs are decoded and +cached before acquiring the audio-device lock. Successful mod activation +invalidates that cache. + +Music/effect gains and channel limits are applied before the device starts. +Changing effect volume updates playing voices; muting clears held samples. +Music volume zero gates FM output while the score and synthesizer continue +advancing, so restoring volume resumes the current music position. + +Portable audio and sound-bank tests cover checked decoding, mixing, pitch, +priority, channel limits, gains, mute behavior and missing/invalid assets. +Optional original-data checks compare FM and digital samples with a small +retained-source harness. Another integration check dispatches all 36 original +cue variants through the production Rust SDL callback and checks their output. + +Local real-device checks on 2026-10-10 used SDL's PulseAudio backend through +PipeWire to the default analog stereo sink. A first-mission run with music +muted produced digital-effect output with peak 16,390 and RMS 6,657.51. A +concurrent sink-monitor recording correlated with the callback output at +0.99929. A seven-package Iron Coast run also produced audible-level output +(peak 16,406; RMS 4,909.62). System volume and mute were unchanged. These +measurements confirm output routing and signal; they do not assess acoustic +balance on every speaker or every cue during ordinary play. + +The final combined build was checked through the same real device after the +mute fix: 172,544 captured stereo frames of first-mission effects had peak +16,384 and RMS 6,506.91. A separate run with both gains at zero captured +54,784 stereo frames with zero nonzero samples, peak zero and RMS zero. + +For a bounded diagnostic capture of the actual production callback, run from +`RustRaptor/` with an isolated profile: + +```sh +RAPTOR_AUDIO_CAPTURE="$PWD/../.reference/audio.wav" \ +RAPTOR_AUDIO_CAPTURE_SECONDS=10 \ +SDL_AUDIODRIVER=pulseaudio ./run.sh \ + --profile-root ../.reference/audio-profile \ + --preview-mission base:sector_1_wave_1 --smoke-frames 540 +``` + +The capture is PCM16 stereo and accepts 1–120 seconds. Configure music and +effects in that profile before capture. Original assets, captures and reports +remain ignored under `../data/` and `../.reference/`. Authored mod WAVs and +their synthesis recipes are tracked in Git. diff --git a/docs/transition-validation.md b/RustRaptor/docs/transition-validation.md similarity index 93% rename from docs/transition-validation.md rename to RustRaptor/docs/transition-validation.md index f59d937d..f68f6c2f 100644 --- a/docs/transition-validation.md +++ b/RustRaptor/docs/transition-validation.md @@ -74,10 +74,10 @@ captured pre-ending behavior; it does not validate the new transition itself. See [campaign-validation.md](campaign-validation.md) for reference rebuild and comparison commands, captured fields, original-data checks and limitations. -The relevant retained sources are `raptor/src/rap.cpp` (`RAP_ThinkStats` and -`Do_Game`), `raptor/src/rap.h` (`END_DURATION`, `END_EXPLODE`, `END_FLYOFF`), -`raptor/src/input.cpp` (`IPT_MovePlayer`, `IPT_FMovePlayer`), -`raptor/src/enemy.cpp` (`ENEMY_Remove`) and `raptor/src/objects.cpp` +The relevant retained sources are `../raptor/src/rap.cpp` (`RAP_ThinkStats` and +`Do_Game`), `../raptor/src/rap.h` (`END_DURATION`, `END_EXPLODE`, `END_FLYOFF`), +`../raptor/src/input.cpp` (`IPT_MovePlayer`, `IPT_FMovePlayer`), +`../raptor/src/enemy.cpp` (`ENEMY_Remove`) and `../raptor/src/objects.cpp` (`OBJS_SubEnergy`). God-mode/debug behavior, rumble, warning-light presentation, cinematic sequencing and a native transition reference are outside this scoped verification. diff --git a/docs/visual-validation.md b/RustRaptor/docs/visual-validation.md similarity index 91% rename from docs/visual-validation.md rename to RustRaptor/docs/visual-validation.md index 241d0825..9fdc57c7 100644 --- a/docs/visual-validation.md +++ b/RustRaptor/docs/visual-validation.md @@ -1,6 +1,6 @@ # Sprite and terrain validation -`tests/visuals.rs` separates portable synthetic checks from local checks of the original catalog. Generated contact sheets and original pixels stay under ignored `.reference/visuals/`; they are not shipped with the engine or committed. +`tests/visuals.rs` separates portable synthetic checks from local checks of the original catalog. Generated contact sheets and original pixels stay under ignored `../.reference/visuals/`; they are not shipped with the engine or committed. The independent test oracle follows the unoptimized corner rules in the [published Scale2x/Scale3x algorithm specification](https://www.scale2x.it/algorithm), with its clamped border sampling. Scale4x applies the independent Scale2x oracle twice. The renderer uses a shared optimized guard, so the oracle and renderer express the rules differently. @@ -21,7 +21,7 @@ cargo test --test visuals original_shadow_tables_and_banking_masks_match_indexed The local checks cover all seven light, dark and firing-flash player banking frames; all 42 `GEXPLO_BLK` ground explosion frames; all 16 `AIRBOOM_PIC` and 13 `EXPLO2_BLK` frames; four micro-missile and gun-ejecta frames; six `SHIP22G1_PIC` boss frames and four `SHIP08G1_PIC` enemy frames. Each original frame is compared against the oracle at 1×, 2×, 3× and 4×, with exact logical canvas dimensions and original palette labels retained. The report records frame IDs, logical canvases, full-canvas center anchors and visible bounds at every scale. -The original 20-byte GFX header contains type, two option fields, width and height; its option fields are not replacement x/y origins. Span x/y coordinates locate pixels within the unchanged canvas. Player rendering uses that top-left canvas origin. Original centered effects use half the full canvas width/height, as recorded by `ANIMS_Register` in `raptor/src/anims.cpp`; visible explosion bounds must not become animation anchors. Missing transparent margins or auto-cropping frames would move those anchors. +The original 20-byte GFX header contains type, two option fields, width and height; its option fields are not replacement x/y origins. Span x/y coordinates locate pixels within the unchanged canvas. Player rendering uses that top-left canvas origin. Original centered effects use half the full canvas width/height, as recorded by `ANIMS_Register` in `../raptor/src/anims.cpp`; visible explosion bounds must not become animation anchors. Missing transparent margins or auto-cropping frames would move those anchors. A three-by-three patch from the first original mission is composed at native resolution and filtered as one image. The center tile filtered with two original pixels of neighboring context must exactly match the center of the full joined patch at 2×, 3× and 4×. The renderer's two-pixel terrain gutter covers Scale4x's two successive passes. This establishes filter consistency across the tested boundaries; it does not assess whether different source tiles were artistically seamless. @@ -52,6 +52,6 @@ The original terrain patch exposed 113 differing pixels at 2×, 297 at 3× and 7 The optional HD player proof remains a separate review: it replaces only straight flight, so banking still changes visual style. The generated extreme-bank attempt remains excluded because it shortened the engines and failed the footprint check; crisp original fallback is retained for that frame. -The frontend previously requested `AIRMED_BLK` for effects, but that name does not exist in the original catalog. Actual original sequences include `AIRBOOM_PIC`, `GEXPLO_BLK`, `EXPLO2_BLK`, flak and sparks. This finding was reported to the engine owner. A generic explosion sequence repairs the visible fallback, but full effect identity, duration, layer, origin and update parity require separate comparison with the C++ animation system. +The frontend previously requested `AIRMED_BLK`, which does not exist in the original catalog. Effects now carry typed identities for the original air/ground explosions, flak, energy, people, flares and sparkles. The renderer selects each sequence's asset, duration, centered canvas origin and ground/air layer. Enemy deaths retain their original multiple bursts and RNG consumption; ground effects follow terrain scroll. This source-based port repairs the missing sequence and generic fallback. Exhaustive visual comparison of every effect remains outside the sampled checks above. The new enemy packages include their generated SD/HD artwork and audio in Git. Their original style-reference pixels under `art/source/` remain excluded. Their per-package `art-qa.json` and `provenance.json` provide a separate record of supplied-art canvas alignment and generation prompts. diff --git a/RustRaptor/docs/weapon-balance.md b/RustRaptor/docs/weapon-balance.md new file mode 100644 index 00000000..0d4befff --- /dev/null +++ b/RustRaptor/docs/weapon-balance.md @@ -0,0 +1,107 @@ +# Shipped weapon prices and balance + +The three independent weapon mods use the original game's credit scale. Their +initial example prices of 6,500, 14,000 and 22,000 made reusable upgrades cheaper +than much weaker original equipment. The revised prices preserve their firing, +damage, scripts, sounds and artwork. + +| Mod weapon | Price | Place in the original progression | +| --- | ---: | --- | +| Pulse Cannon | 185,000 | Just above the 175,600-credit Micro Missile primary upgrade. It adds similar sustained damage and also adds a spread burst every third volley. | +| Seeker Swarm | 285,000 | Above the 250,650-credit Mini Gun. Its three missiles track air and ground enemies and hits fragment into at most two nearby enemies. It remains below the 512,850-credit instant Turret. | +| Ion Lance | 425,000 | Below the Turret and the 950,000-credit Death Ray. Its lower damage per target trades against immediate hits and the ability to pierce several enemies in one column. | + +Pulse Cannon is always equipped and fires alongside other primary guns. Seeker +Swarm and Ion Lance occupy the selected special slot and fire with Fire or +Special while selected. All three are reusable, consume no ammunition and have +a maximum inventory count of one. Each equipment definition includes a shop +description stating these controls and its target behavior. + +## Controlled damage observations + +The local benchmark loads the original asset catalog and original registered +weapons, plus all six shipped weapon/enemy packages. Every weapon runs through +the real `World::step` simulation and real scripts at difficulty 2. Each scene +holds Fire for 700 steps, exactly 30 seconds at 70/3 Hz, against 32×32 fixtures +with 1,000,000 health. The pilot remains at the initial position with no other +weapons equipped. Ground fixtures use a test-only movement callback to cancel +the normal one-pixel terrain scroll after each collision pass. + +The numbers below include travel delay and misses during that 30-second window. +They measure health lost per second, rather than nominal damage divided by +cooldown. + +| Weapon | Price | Aligned air damage/s | Aligned ground damage/s | Air target 76 pixels to the right damage/s | +| --- | ---: | ---: | ---: | ---: | +| Original Forward Guns | 12,000 | 23.13 | 23.13 | 0 | +| Original Plasma Guns | 78,800 | 4.67 | 0 | 0 | +| Original Micro Missile | 175,600 | 23.07 | 23.07 | 0 | +| Original Mini Gun | 250,650 | 12.57 | 12.57 | 16.27 | +| Original Turret | 512,850 | 19.50 | 0 | 19.50 | +| Original Pulse Cannon | 725,000 | 38.50 | 38.50 | 0 | +| Original Death Ray | 950,000 | 80.00 | 80.00 | 0 | +| Original Forward Laser | 1,750,000 | 266.67 | 266.67 | 0 | +| Mod Pulse Cannon | 185,000 | 24.00 | 24.00 | 0 | +| Seeker Swarm | 285,000 | 23.40 | 23.40 | 23.40 | +| Ion Lance | 425,000 | 26.67 | 26.67 | 0 | + +The original forward laser's native beam damage pass reaches ground enemies +despite its historical air target metadata; the observations use its actual +behavior. The original turret selects airborne enemies. Original weapons retain +their native update and collision paths throughout this comparison. + +Equipping Pulse Cannon with the starter Forward Guns produced 46.33 damage/s +against either aligned target, compared with 46.20 for Forward Guns plus Micro +Missile. Pulse therefore belongs beside that original primary upgrade, rather +than below the 12,000-credit starter guns. Scripted angled shots also reached a +second target in the cluster scene. + +Seeker Swarm delivered 23.40 damage/s to an offset airborne or ground target. +In a tight three-enemy cluster, it delivered 23.40 to the primary target plus +7.80 to each of two neighbors: 39.00 combined. Its fragmentation is bounded; +it does not damage every enemy on screen. + +Ion Lance delivered 26.67 damage/s to each of two vertically separated targets +in the same column: 53.33 combined. It delivered 80.00 combined across three +overlapping column targets. It dealt zero damage to targets outside its launch +column. It is a brief aimed beam, rather than a screen-wide attack. + +## Economy and limits + +Controlled first-mission runs starting with 100 energy and zero credits earned +42,775 credits at difficulty 1 and 59,025 at difficulty 2 with starter guns. +The in-game registration flow starts with 10,000 credits and 75 energy, matching +the original hangar setup. Compared with the latter controlled gross +reward, these prices represent approximately 3.13, 4.83 and 7.20 such payouts. +Repairs and other purchases increase the saving time; later missions have +different rewards. This comparison sets a progression tier and does not promise +an exact purchase mission. + +Stationary fixtures favor perfect alignment and long-lived targets. They do +not establish equal campaign difficulty, measure moving-target accuracy, +account for overkill against weak enemies or quantify projectile travel time +against fast enemies. The benchmark also isolates weapons; real pilots can +combine several primaries with one selected special. These prices are concrete +initial balance choices, not an assertion that every loadout is equally strong. + +Iron Coast Mission 1 intentionally supplies a free Pulse Cannon loan if the +pilot does not already own one. The mission returns the loan at its end while +preserving previously owned equipment. The trial therefore helps this mission +without granting a permanent shop upgrade or a repeatable resale reward. + +## Reproduce + +Run the local-only test from the repository root with the original catalog: + +```sh +RAPTOR_BALANCE_REPORT="$PWD/.reference/weapon-balance.json" \ + cargo test --release --manifest-path RustRaptor/Cargo.toml \ + --no-default-features --test weapon_balance -- --ignored --nocapture +``` + +`RAPTOR_TEST_DATA` can override the catalog directory. The optional JSON report +records all 96 observations: fifteen weapons in six scenes, plus the three +primary upgrades combined with Forward Guns against air and ground. Assertions +check all-target homing, column piercing, off-axis beam misses, target survival +and unchanged pilot shields. The test uses the Rust engine without SDL or C++. +Original data and locally written reference reports stay outside Git. diff --git a/examples/mods/aurora/README.md b/RustRaptor/mods/aurora/README.md similarity index 59% rename from examples/mods/aurora/README.md rename to RustRaptor/mods/aurora/README.md index b94981d3..3a536f73 100644 --- a/examples/mods/aurora/README.md +++ b/RustRaptor/mods/aurora/README.md @@ -5,11 +5,11 @@ This API v1 example adds a scripted pulse cannon, a repair pickup, weaving drone From the repository root: ```sh -cargo run --no-default-features -- --mods examples/mods --validate-mods aurora -cargo run --no-default-features -- --mods examples/mods --enabled aurora \ +cargo run --no-default-features -- --mods mods --validate-mods aurora +cargo run --no-default-features -- --mods mods --enabled aurora \ --headless aurora:field_trials --ticks 700 ``` -To play, start the Rust engine with `--mods examples/mods`, enable Aurora in **Mods**, apply the selection at the main menu, create a separate pilot and choose **Aurora Field Trials**. The mission equips its cannon on the first simulation step. Keep existing pilots' required mod sets available. +To play, start the Rust engine with `--mods mods`, enable Aurora in **Mods**, apply the selection at the main menu, create a separate pilot and choose **Aurora Field Trials**. The mission equips its cannon on the first simulation step. Keep existing pilots' required mod sets available. Content IDs intentionally separate the inventory item `aurora:pulse_cannon` from its projectile `aurora:pulse_projectile`. The scripts demonstrate `on_fire`, `on_hit`, `on_spawn`, `on_tick`, `on_death`, `on_pickup`, `on_use`, `on_mission_start` and `on_mission_end`. See `docs/modding-api.md` for the complete API and compatibility rules. diff --git a/examples/mods/aurora/content.json b/RustRaptor/mods/aurora/content.json similarity index 100% rename from examples/mods/aurora/content.json rename to RustRaptor/mods/aurora/content.json diff --git a/examples/mods/aurora/mod.toml b/RustRaptor/mods/aurora/mod.toml similarity index 100% rename from examples/mods/aurora/mod.toml rename to RustRaptor/mods/aurora/mod.toml diff --git a/examples/mods/aurora/scripts/cell.rhai b/RustRaptor/mods/aurora/scripts/cell.rhai similarity index 100% rename from examples/mods/aurora/scripts/cell.rhai rename to RustRaptor/mods/aurora/scripts/cell.rhai diff --git a/examples/mods/aurora/scripts/drone.rhai b/RustRaptor/mods/aurora/scripts/drone.rhai similarity index 100% rename from examples/mods/aurora/scripts/drone.rhai rename to RustRaptor/mods/aurora/scripts/drone.rhai diff --git a/examples/mods/aurora/scripts/mission.rhai b/RustRaptor/mods/aurora/scripts/mission.rhai similarity index 100% rename from examples/mods/aurora/scripts/mission.rhai rename to RustRaptor/mods/aurora/scripts/mission.rhai diff --git a/examples/mods/aurora/scripts/weapon.rhai b/RustRaptor/mods/aurora/scripts/weapon.rhai similarity index 100% rename from examples/mods/aurora/scripts/weapon.rhai rename to RustRaptor/mods/aurora/scripts/weapon.rhai diff --git a/examples/mods/ion_lance/ART_PROVENANCE.md b/RustRaptor/mods/ion_lance/ART_PROVENANCE.md similarity index 100% rename from examples/mods/ion_lance/ART_PROVENANCE.md rename to RustRaptor/mods/ion_lance/ART_PROVENANCE.md diff --git a/examples/mods/ion_lance/README.md b/RustRaptor/mods/ion_lance/README.md similarity index 72% rename from examples/mods/ion_lance/README.md rename to RustRaptor/mods/ion_lance/README.md index f08f7fe3..c56509b8 100644 --- a/examples/mods/ion_lance/README.md +++ b/RustRaptor/mods/ion_lance/README.md @@ -1,9 +1,9 @@ # Ion Lance 1.0.0 -Enable **Ion Lance** in Mods and apply at the main menu. This independent API 1 example has no dependencies. Buy its equipment in the shop (22,000 credits, maximum one) or collect its pickup. Choose it in Inventory or cycle special weapons during a mission. The equipped weapon fires with Fire or Special; it consumes no ammunition. +Enable **Ion Lance** in Mods and apply at the main menu. This independent API 1 example has no dependencies. Buy its equipment in the shop (425,000 credits, maximum one) or collect its pickup. Choose it in Inventory or cycle special weapons during a mission. The equipped weapon fires with Fire or Special; it consumes no ammunition. Projectile: 8 damage, 12 logical pixels per step, 14 step cooldown, `beam`, air and ground targets. The narrow beam pierces every enemy in its column for two steps (16 total damage per enemy per trigger). Rhai stores bounded burst/hit counters and emits local firing/impact sounds and effects. `art/equipment-sd.png` is a 16×16 logical icon, `art/equipment-hd.png` its detailed 4× variant. Projectile canvases are 4×32 SD and 16×128 HD. Switch HD artwork in Options. The generated master images, SD/HD sprites, WAV files, provenance and reproduction inputs are tracked in Git. Original game assets and decoded reference images remain outside version control. `python3 generate_audio.py` reproduces both sound files. -Validate locally with `cargo run --no-default-features -- --mods examples/mods --validate-mods ion_lance`. Read the shared [API documentation](../../../docs/modding-api.md) for save pins, dependencies and sandbox limits. +Validate locally with `cargo run --no-default-features -- --mods mods --validate-mods ion_lance`. Read the shared [API documentation](../../docs/modding-api.md) for save pins, dependencies and sandbox limits. diff --git a/examples/mods/ion_lance/art-provenance.json b/RustRaptor/mods/ion_lance/art-provenance.json similarity index 100% rename from examples/mods/ion_lance/art-provenance.json rename to RustRaptor/mods/ion_lance/art-provenance.json diff --git a/examples/mods/ion_lance/art/equipment-generated-source.png b/RustRaptor/mods/ion_lance/art/equipment-generated-source.png similarity index 100% rename from examples/mods/ion_lance/art/equipment-generated-source.png rename to RustRaptor/mods/ion_lance/art/equipment-generated-source.png diff --git a/examples/mods/ion_lance/art/equipment-hd.png b/RustRaptor/mods/ion_lance/art/equipment-hd.png similarity index 100% rename from examples/mods/ion_lance/art/equipment-hd.png rename to RustRaptor/mods/ion_lance/art/equipment-hd.png diff --git a/examples/mods/ion_lance/art/equipment-sd.png b/RustRaptor/mods/ion_lance/art/equipment-sd.png similarity index 100% rename from examples/mods/ion_lance/art/equipment-sd.png rename to RustRaptor/mods/ion_lance/art/equipment-sd.png diff --git a/examples/mods/ion_lance/art/projectile-generated-source.png b/RustRaptor/mods/ion_lance/art/projectile-generated-source.png similarity index 100% rename from examples/mods/ion_lance/art/projectile-generated-source.png rename to RustRaptor/mods/ion_lance/art/projectile-generated-source.png diff --git a/examples/mods/ion_lance/art/projectile-hd.png b/RustRaptor/mods/ion_lance/art/projectile-hd.png similarity index 100% rename from examples/mods/ion_lance/art/projectile-hd.png rename to RustRaptor/mods/ion_lance/art/projectile-hd.png diff --git a/examples/mods/ion_lance/art/projectile-sd.png b/RustRaptor/mods/ion_lance/art/projectile-sd.png similarity index 100% rename from examples/mods/ion_lance/art/projectile-sd.png rename to RustRaptor/mods/ion_lance/art/projectile-sd.png diff --git a/examples/mods/ion_lance/audio/fire.wav b/RustRaptor/mods/ion_lance/audio/fire.wav similarity index 100% rename from examples/mods/ion_lance/audio/fire.wav rename to RustRaptor/mods/ion_lance/audio/fire.wav diff --git a/examples/mods/ion_lance/audio/impact.wav b/RustRaptor/mods/ion_lance/audio/impact.wav similarity index 100% rename from examples/mods/ion_lance/audio/impact.wav rename to RustRaptor/mods/ion_lance/audio/impact.wav diff --git a/examples/mods/ion_lance/content.json b/RustRaptor/mods/ion_lance/content.json similarity index 82% rename from examples/mods/ion_lance/content.json rename to RustRaptor/mods/ion_lance/content.json index ed13887c..5f48ef2b 100644 --- a/examples/mods/ion_lance/content.json +++ b/RustRaptor/mods/ion_lance/content.json @@ -28,7 +28,7 @@ { "id": "ion_lance:equipment", "name": "Ion Lance", - "price": 22000, + "price": 425000, "max_count": 1, "initial_count": 1, "weapon": "ion_lance:projectile", @@ -41,7 +41,8 @@ "logical_height": 16, "nearest": true }, - "script": "scripts/equipment.rhai" + "script": "scripts/equipment.rhai", + "description": "Selectable reusable special. A brief beam hits every air or ground enemy in the column ahead of your ship. Aim the column, then use Fire or Special while selected. No ammunition is consumed." } ], "sounds": [ diff --git a/examples/mods/ion_lance/generate_audio.py b/RustRaptor/mods/ion_lance/generate_audio.py similarity index 100% rename from examples/mods/ion_lance/generate_audio.py rename to RustRaptor/mods/ion_lance/generate_audio.py diff --git a/examples/mods/ion_lance/mod.toml b/RustRaptor/mods/ion_lance/mod.toml similarity index 100% rename from examples/mods/ion_lance/mod.toml rename to RustRaptor/mods/ion_lance/mod.toml diff --git a/examples/mods/ion_lance/prepare_art.rs b/RustRaptor/mods/ion_lance/prepare_art.rs similarity index 100% rename from examples/mods/ion_lance/prepare_art.rs rename to RustRaptor/mods/ion_lance/prepare_art.rs diff --git a/examples/mods/ion_lance/scripts/equipment.rhai b/RustRaptor/mods/ion_lance/scripts/equipment.rhai similarity index 100% rename from examples/mods/ion_lance/scripts/equipment.rhai rename to RustRaptor/mods/ion_lance/scripts/equipment.rhai diff --git a/examples/mods/ion_lance/scripts/weapon.rhai b/RustRaptor/mods/ion_lance/scripts/weapon.rhai similarity index 80% rename from examples/mods/ion_lance/scripts/weapon.rhai rename to RustRaptor/mods/ion_lance/scripts/weapon.rhai index 4a1edd61..5383d167 100644 --- a/examples/mods/ion_lance/scripts/weapon.rhai +++ b/RustRaptor/mods/ion_lance/scripts/weapon.rhai @@ -1,11 +1,10 @@ // Built-in beam projectiles handle the normal volley and collision rules. -// Callbacks add deterministic visuals, audio and serializable weapon counters. +// Firing callbacks add audio and counters; impact callbacks add hit visuals. fn on_fire(event, state, random) { let bursts = if state.contains("bursts") { state.bursts % 100000 + 1 } else { 1 }; let hits = if state.contains("hits") { state.hits } else { 0 }; let commands = [ - #{ op: "play_sound", sound: "ion_lance:fire" }, -#{ op: "effect", x: event.x, y: event.y - 3 } + #{ op: "play_sound", sound: "ion_lance:fire" } ]; #{ commands: commands, state: #{ bursts: bursts, hits: hits } } } diff --git a/examples/mods/iron_coast_boss/README.md b/RustRaptor/mods/iron_coast_boss/README.md similarity index 88% rename from examples/mods/iron_coast_boss/README.md rename to RustRaptor/mods/iron_coast_boss/README.md index abb46ed1..93e3a658 100644 --- a/examples/mods/iron_coast_boss/README.md +++ b/RustRaptor/mods/iron_coast_boss/README.md @@ -14,4 +14,4 @@ Generated PNG masters, SD/HD strips, previews and WAV sounds are included in Git To rebuild derived artwork from supplied generated masters and regenerate audio, install Pillow and run `python3 build_assets.py`. To regenerate only audio with the Python standard library, run `python3 build_assets.py --audio-only`. WAVs are original procedural mono 16-bit PCM at 22050 Hz; `audio-recipe.json` records deterministic seeds, sizes and hashes. The art pipeline resizes and assembles supplied images; it does not redraw them. -Validate from the repository root: `cargo run -- --mods examples/mods --validate-mods iron_coast_boss`. Synthetic behavior and save/replay tests: `cargo test --test enemies_mods`. Verify shipped artwork locally: `cargo test --test enemies_mods shipped_enemy_assets_are_valid_and_aligned`. +Validate from the repository root: `cargo run -- --mods mods --validate-mods iron_coast_boss`. Synthetic behavior and save/replay tests: `cargo test --test enemies_mods`. Verify shipped artwork locally: `cargo test --test enemies_mods shipped_enemy_assets_are_valid_and_aligned`. diff --git a/examples/mods/iron_coast_boss/art-qa.json b/RustRaptor/mods/iron_coast_boss/art-qa.json similarity index 100% rename from examples/mods/iron_coast_boss/art-qa.json rename to RustRaptor/mods/iron_coast_boss/art-qa.json diff --git a/examples/mods/iron_coast_boss/art/preview.png b/RustRaptor/mods/iron_coast_boss/art/preview.png similarity index 100% rename from examples/mods/iron_coast_boss/art/preview.png rename to RustRaptor/mods/iron_coast_boss/art/preview.png diff --git a/examples/mods/iron_coast_boss/art/projectile-generated-v1.png b/RustRaptor/mods/iron_coast_boss/art/projectile-generated-v1.png similarity index 100% rename from examples/mods/iron_coast_boss/art/projectile-generated-v1.png rename to RustRaptor/mods/iron_coast_boss/art/projectile-generated-v1.png diff --git a/examples/mods/iron_coast_boss/art/projectile-hd.png b/RustRaptor/mods/iron_coast_boss/art/projectile-hd.png similarity index 100% rename from examples/mods/iron_coast_boss/art/projectile-hd.png rename to RustRaptor/mods/iron_coast_boss/art/projectile-hd.png diff --git a/examples/mods/iron_coast_boss/art/projectile-sd.png b/RustRaptor/mods/iron_coast_boss/art/projectile-sd.png similarity index 100% rename from examples/mods/iron_coast_boss/art/projectile-sd.png rename to RustRaptor/mods/iron_coast_boss/art/projectile-sd.png diff --git a/examples/mods/iron_coast_boss/art/unit-charge-generated-v1.png b/RustRaptor/mods/iron_coast_boss/art/unit-charge-generated-v1.png similarity index 100% rename from examples/mods/iron_coast_boss/art/unit-charge-generated-v1.png rename to RustRaptor/mods/iron_coast_boss/art/unit-charge-generated-v1.png diff --git a/examples/mods/iron_coast_boss/art/unit-generated-v1.png b/RustRaptor/mods/iron_coast_boss/art/unit-generated-v1.png similarity index 100% rename from examples/mods/iron_coast_boss/art/unit-generated-v1.png rename to RustRaptor/mods/iron_coast_boss/art/unit-generated-v1.png diff --git a/examples/mods/iron_coast_boss/art/unit-hd.png b/RustRaptor/mods/iron_coast_boss/art/unit-hd.png similarity index 100% rename from examples/mods/iron_coast_boss/art/unit-hd.png rename to RustRaptor/mods/iron_coast_boss/art/unit-hd.png diff --git a/examples/mods/iron_coast_boss/art/unit-sd.png b/RustRaptor/mods/iron_coast_boss/art/unit-sd.png similarity index 100% rename from examples/mods/iron_coast_boss/art/unit-sd.png rename to RustRaptor/mods/iron_coast_boss/art/unit-sd.png diff --git a/examples/mods/iron_coast_boss/audio-recipe.json b/RustRaptor/mods/iron_coast_boss/audio-recipe.json similarity index 100% rename from examples/mods/iron_coast_boss/audio-recipe.json rename to RustRaptor/mods/iron_coast_boss/audio-recipe.json diff --git a/examples/mods/iron_coast_boss/audio/death.wav b/RustRaptor/mods/iron_coast_boss/audio/death.wav similarity index 100% rename from examples/mods/iron_coast_boss/audio/death.wav rename to RustRaptor/mods/iron_coast_boss/audio/death.wav diff --git a/examples/mods/iron_coast_boss/audio/shot.wav b/RustRaptor/mods/iron_coast_boss/audio/shot.wav similarity index 100% rename from examples/mods/iron_coast_boss/audio/shot.wav rename to RustRaptor/mods/iron_coast_boss/audio/shot.wav diff --git a/examples/mods/iron_coast_boss/build_assets.py b/RustRaptor/mods/iron_coast_boss/build_assets.py similarity index 97% rename from examples/mods/iron_coast_boss/build_assets.py rename to RustRaptor/mods/iron_coast_boss/build_assets.py index 496fcc88..46ef58ef 100644 --- a/examples/mods/iron_coast_boss/build_assets.py +++ b/RustRaptor/mods/iron_coast_boss/build_assets.py @@ -2,8 +2,8 @@ """Convert saved imagegen artwork and synthesize reproducible package-local WAVs. This script resizes/atlases supplied art; it never redraws source or generated art. -Generated source PNGs stay in ignored art/. See prompts/ and provenance.json. -Use --audio-only to regenerate sound without Pillow or private artwork. +Generated masters, SD/HD artwork and WAVs are tracked in Git. See prompts/ and provenance.json. +Use --audio-only to regenerate sound without Pillow or processing artwork. """ import argparse import hashlib diff --git a/examples/mods/iron_coast_boss/content.json b/RustRaptor/mods/iron_coast_boss/content.json similarity index 100% rename from examples/mods/iron_coast_boss/content.json rename to RustRaptor/mods/iron_coast_boss/content.json diff --git a/examples/mods/iron_coast_boss/mod.toml b/RustRaptor/mods/iron_coast_boss/mod.toml similarity index 100% rename from examples/mods/iron_coast_boss/mod.toml rename to RustRaptor/mods/iron_coast_boss/mod.toml diff --git a/examples/mods/iron_coast_boss/prompts/projectile-hd-v1.txt b/RustRaptor/mods/iron_coast_boss/prompts/projectile-hd-v1.txt similarity index 100% rename from examples/mods/iron_coast_boss/prompts/projectile-hd-v1.txt rename to RustRaptor/mods/iron_coast_boss/prompts/projectile-hd-v1.txt diff --git a/examples/mods/iron_coast_boss/prompts/unit-charge-v1.txt b/RustRaptor/mods/iron_coast_boss/prompts/unit-charge-v1.txt similarity index 100% rename from examples/mods/iron_coast_boss/prompts/unit-charge-v1.txt rename to RustRaptor/mods/iron_coast_boss/prompts/unit-charge-v1.txt diff --git a/examples/mods/iron_coast_boss/prompts/unit-hd-v1.txt b/RustRaptor/mods/iron_coast_boss/prompts/unit-hd-v1.txt similarity index 100% rename from examples/mods/iron_coast_boss/prompts/unit-hd-v1.txt rename to RustRaptor/mods/iron_coast_boss/prompts/unit-hd-v1.txt diff --git a/examples/mods/iron_coast_boss/provenance.json b/RustRaptor/mods/iron_coast_boss/provenance.json similarity index 100% rename from examples/mods/iron_coast_boss/provenance.json rename to RustRaptor/mods/iron_coast_boss/provenance.json diff --git a/examples/mods/iron_coast_boss/scripts/ai.rhai b/RustRaptor/mods/iron_coast_boss/scripts/ai.rhai similarity index 100% rename from examples/mods/iron_coast_boss/scripts/ai.rhai rename to RustRaptor/mods/iron_coast_boss/scripts/ai.rhai diff --git a/examples/mods/iron_coast_mission_1/README.md b/RustRaptor/mods/iron_coast_mission_1/README.md similarity index 78% rename from examples/mods/iron_coast_mission_1/README.md rename to RustRaptor/mods/iron_coast_mission_1/README.md index 8f320de2..ecacdb82 100644 --- a/examples/mods/iron_coast_mission_1/README.md +++ b/RustRaptor/mods/iron_coast_mission_1/README.md @@ -1,6 +1,6 @@ # Iron Coast — Mission 1: Breakwater -Enable this package and its six dependencies in the in-game Mods screen: +Enable this package in the in-game Mods screen to select its six installed dependencies automatically: `iron_coast_scout`, `iron_coast_turret`, `iron_coast_boss`, `pulse_cannon`, `seeker_swarm`, and `ion_lance`. Each package is independent and version 1.0.0. Choose **Iron Coast 1 — Breakwater** in the mission list. @@ -8,7 +8,10 @@ Choose **Iron Coast 1 — Breakwater** in the mission list. Disable at least two harbor turrets and destroy the command ship to complete the raid. Scout waves arrive during the approach; two supply depots release repair kits. When inventory space allows, the mission grants a trial Pulse -Cannon and three field repairs. Granted equipment remains with the pilot. +Cannon loan and three field repairs. The loan is returned before the completed +flight is saved; a cannon you already own is preserved. Field repairs cost +12,500 credits each in the shop, a premium over the original 10,000-credit +hangar energy refill for use during combat. Repairs restore 25 shield when selected and used with the special button. Victory awards 5,000 credits in addition to ordinary destruction rewards. A full inventory can still start the mission; existing equipment is preserved @@ -25,10 +28,10 @@ report. Original game data and the original style-reference PNGs remain outside Git. The three `art/*-source-hd.png` inputs were generated with the built-in imagegen tool using the original terrain and military sprite as style/scale references. -Run `python3 examples/mods/iron_coast_mission_1/build_assets.py --palette -data/misc/PALETTE_DAT` to regenerate the exact +Run `python3 mods/iron_coast_mission_1/build_assets.py --palette +../data/misc/PALETTE_DAT` to regenerate the exact packaged crops, SD palette conversion, HD footprints and synthesized WAVs. -See `prompts.md`, `asset-report.json`, and `docs/iron-coast.md` for provenance +See `prompts.md`, `asset-report.json`, and [Iron Coast documentation](../../docs/iron-coast.md) for provenance and validation limits. The release acceptance run completes with starter guns at step **882** diff --git a/examples/mods/iron_coast_mission_1/art/briefing-source-hd.png b/RustRaptor/mods/iron_coast_mission_1/art/briefing-source-hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/briefing-source-hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/briefing-source-hd.png diff --git a/examples/mods/iron_coast_mission_1/art/briefing_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/briefing_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/briefing_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/briefing_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/briefing_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/briefing_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/briefing_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/briefing_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/depot-source-hd.png b/RustRaptor/mods/iron_coast_mission_1/art/depot-source-hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/depot-source-hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/depot-source-hd.png diff --git a/examples/mods/iron_coast_mission_1/art/depot_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/depot_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/depot_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/depot_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/depot_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/depot_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/depot_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/depot_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/repair_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/repair_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/repair_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/repair_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/repair_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/repair_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/repair_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/repair_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/terrain-preview-hd.png b/RustRaptor/mods/iron_coast_mission_1/art/terrain-preview-hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/terrain-preview-hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/terrain-preview-hd.png diff --git a/examples/mods/iron_coast_mission_1/art/terrain-preview-sd.png b/RustRaptor/mods/iron_coast_mission_1/art/terrain-preview-sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/terrain-preview-sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/terrain-preview-sd.png diff --git a/examples/mods/iron_coast_mission_1/art/terrain-source-hd.png b/RustRaptor/mods/iron_coast_mission_1/art/terrain-source-hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/terrain-source-hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/terrain-source-hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_000_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_000_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_000_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_000_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_000_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_000_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_000_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_000_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_001_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_001_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_001_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_001_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_001_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_001_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_001_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_001_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_002_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_002_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_002_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_002_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_002_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_002_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_002_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_002_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_003_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_003_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_003_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_003_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_003_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_003_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_003_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_003_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_004_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_004_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_004_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_004_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_004_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_004_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_004_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_004_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_005_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_005_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_005_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_005_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_005_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_005_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_005_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_005_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_006_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_006_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_006_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_006_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_006_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_006_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_006_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_006_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_007_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_007_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_007_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_007_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_007_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_007_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_007_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_007_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_008_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_008_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_008_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_008_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_008_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_008_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_008_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_008_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_009_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_009_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_009_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_009_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_009_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_009_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_009_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_009_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_010_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_010_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_010_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_010_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_010_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_010_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_010_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_010_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_011_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_011_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_011_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_011_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_011_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_011_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_011_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_011_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_012_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_012_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_012_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_012_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_012_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_012_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_012_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_012_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_013_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_013_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_013_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_013_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_013_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_013_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_013_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_013_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_014_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_014_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_014_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_014_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_014_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_014_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_014_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_014_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_015_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_015_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_015_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_015_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_015_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_015_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_015_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_015_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_016_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_016_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_016_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_016_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_016_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_016_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_016_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_016_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_017_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_017_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_017_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_017_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_017_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_017_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_017_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_017_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_018_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_018_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_018_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_018_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_018_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_018_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_018_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_018_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_019_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_019_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_019_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_019_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_019_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_019_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_019_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_019_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_020_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_020_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_020_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_020_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_020_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_020_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_020_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_020_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_021_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_021_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_021_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_021_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_021_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_021_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_021_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_021_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_022_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_022_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_022_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_022_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_022_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_022_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_022_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_022_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_023_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_023_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_023_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_023_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_023_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_023_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_023_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_023_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_024_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_024_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_024_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_024_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_024_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_024_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_024_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_024_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_025_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_025_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_025_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_025_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_025_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_025_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_025_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_025_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_026_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_026_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_026_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_026_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_026_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_026_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_026_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_026_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_027_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_027_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_027_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_027_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_027_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_027_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_027_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_027_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_028_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_028_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_028_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_028_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_028_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_028_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_028_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_028_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_029_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_029_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_029_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_029_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_029_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_029_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_029_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_029_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_030_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_030_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_030_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_030_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_030_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_030_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_030_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_030_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_031_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_031_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_031_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_031_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_031_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_031_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_031_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_031_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_032_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_032_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_032_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_032_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_032_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_032_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_032_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_032_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_033_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_033_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_033_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_033_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_033_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_033_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_033_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_033_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_034_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_034_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_034_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_034_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_034_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_034_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_034_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_034_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_035_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_035_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_035_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_035_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_035_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_035_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_035_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_035_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_036_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_036_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_036_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_036_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_036_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_036_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_036_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_036_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_037_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_037_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_037_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_037_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_037_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_037_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_037_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_037_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_038_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_038_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_038_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_038_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_038_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_038_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_038_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_038_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_039_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_039_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_039_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_039_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_039_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_039_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_039_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_039_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_040_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_040_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_040_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_040_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_040_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_040_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_040_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_040_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_041_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_041_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_041_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_041_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_041_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_041_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_041_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_041_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_042_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_042_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_042_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_042_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_042_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_042_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_042_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_042_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_043_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_043_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_043_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_043_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_043_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_043_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_043_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_043_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_044_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_044_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_044_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_044_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_044_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_044_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_044_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_044_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_045_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_045_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_045_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_045_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_045_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_045_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_045_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_045_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_046_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_046_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_046_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_046_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_046_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_046_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_046_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_046_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_047_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_047_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_047_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_047_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_047_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_047_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_047_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_047_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_048_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_048_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_048_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_048_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_048_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_048_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_048_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_048_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_049_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_049_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_049_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_049_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_049_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_049_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_049_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_049_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_050_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_050_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_050_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_050_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_050_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_050_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_050_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_050_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_051_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_051_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_051_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_051_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_051_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_051_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_051_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_051_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_052_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_052_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_052_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_052_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_052_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_052_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_052_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_052_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_053_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_053_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_053_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_053_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_053_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_053_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_053_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_053_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_054_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_054_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_054_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_054_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_054_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_054_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_054_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_054_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_055_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_055_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_055_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_055_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_055_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_055_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_055_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_055_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_056_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_056_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_056_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_056_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_056_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_056_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_056_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_056_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_057_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_057_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_057_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_057_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_057_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_057_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_057_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_057_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_058_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_058_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_058_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_058_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_058_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_058_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_058_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_058_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_059_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_059_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_059_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_059_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_059_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_059_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_059_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_059_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_060_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_060_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_060_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_060_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_060_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_060_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_060_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_060_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_061_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_061_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_061_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_061_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_061_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_061_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_061_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_061_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_062_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_062_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_062_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_062_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_062_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_062_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_062_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_062_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_063_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_063_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_063_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_063_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_063_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_063_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_063_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_063_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_064_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_064_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_064_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_064_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_064_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_064_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_064_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_064_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_065_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_065_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_065_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_065_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_065_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_065_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_065_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_065_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_066_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_066_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_066_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_066_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_066_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_066_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_066_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_066_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_067_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_067_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_067_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_067_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_067_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_067_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_067_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_067_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_068_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_068_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_068_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_068_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_068_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_068_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_068_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_068_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_069_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_069_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_069_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_069_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_069_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_069_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_069_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_069_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_070_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_070_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_070_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_070_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_070_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_070_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_070_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_070_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_071_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_071_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_071_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_071_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_071_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_071_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_071_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_071_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_072_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_072_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_072_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_072_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_072_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_072_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_072_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_072_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_073_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_073_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_073_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_073_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_073_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_073_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_073_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_073_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_074_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_074_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_074_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_074_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_074_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_074_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_074_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_074_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_075_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_075_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_075_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_075_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_075_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_075_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_075_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_075_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_076_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_076_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_076_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_076_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_076_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_076_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_076_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_076_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_077_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_077_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_077_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_077_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_077_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_077_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_077_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_077_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_078_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_078_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_078_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_078_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_078_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_078_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_078_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_078_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_079_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_079_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_079_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_079_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_079_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_079_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_079_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_079_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_080_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_080_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_080_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_080_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_080_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_080_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_080_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_080_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_081_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_081_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_081_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_081_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_081_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_081_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_081_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_081_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_082_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_082_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_082_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_082_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_082_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_082_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_082_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_082_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_083_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_083_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_083_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_083_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_083_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_083_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_083_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_083_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_084_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_084_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_084_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_084_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_084_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_084_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_084_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_084_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_085_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_085_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_085_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_085_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_085_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_085_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_085_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_085_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_086_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_086_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_086_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_086_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_086_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_086_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_086_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_086_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_087_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_087_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_087_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_087_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_087_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_087_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_087_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_087_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_088_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_088_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_088_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_088_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_088_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_088_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_088_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_088_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_089_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_089_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_089_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_089_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_089_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_089_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_089_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_089_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_090_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_090_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_090_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_090_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_090_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_090_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_090_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_090_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_091_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_091_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_091_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_091_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_091_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_091_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_091_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_091_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_092_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_092_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_092_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_092_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_092_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_092_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_092_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_092_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_093_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_093_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_093_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_093_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_093_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_093_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_093_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_093_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_094_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_094_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_094_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_094_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_094_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_094_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_094_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_094_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_095_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_095_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_095_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_095_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_095_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_095_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_095_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_095_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_096_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_096_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_096_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_096_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_096_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_096_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_096_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_096_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_097_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_097_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_097_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_097_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_097_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_097_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_097_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_097_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_098_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_098_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_098_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_098_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_098_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_098_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_098_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_098_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_099_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_099_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_099_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_099_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_099_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_099_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_099_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_099_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_100_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_100_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_100_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_100_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_100_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_100_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_100_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_100_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_101_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_101_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_101_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_101_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_101_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_101_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_101_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_101_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_102_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_102_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_102_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_102_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_102_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_102_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_102_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_102_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_103_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_103_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_103_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_103_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_103_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_103_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_103_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_103_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_104_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_104_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_104_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_104_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_104_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_104_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_104_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_104_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_105_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_105_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_105_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_105_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_105_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_105_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_105_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_105_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_106_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_106_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_106_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_106_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_106_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_106_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_106_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_106_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_107_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_107_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_107_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_107_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_107_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_107_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_107_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_107_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_108_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_108_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_108_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_108_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_108_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_108_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_108_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_108_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_109_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_109_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_109_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_109_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_109_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_109_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_109_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_109_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_110_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_110_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_110_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_110_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_110_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_110_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_110_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_110_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_111_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_111_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_111_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_111_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_111_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_111_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_111_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_111_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_112_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_112_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_112_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_112_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_112_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_112_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_112_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_112_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_113_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_113_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_113_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_113_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_113_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_113_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_113_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_113_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_114_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_114_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_114_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_114_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_114_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_114_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_114_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_114_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_115_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_115_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_115_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_115_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_115_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_115_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_115_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_115_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_116_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_116_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_116_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_116_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_116_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_116_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_116_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_116_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_117_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_117_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_117_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_117_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_117_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_117_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_117_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_117_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_118_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_118_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_118_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_118_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_118_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_118_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_118_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_118_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_119_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_119_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_119_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_119_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_119_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_119_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_119_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_119_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_120_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_120_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_120_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_120_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_120_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_120_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_120_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_120_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_121_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_121_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_121_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_121_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_121_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_121_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_121_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_121_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_122_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_122_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_122_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_122_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_122_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_122_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_122_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_122_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_123_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_123_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_123_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_123_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_123_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_123_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_123_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_123_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_124_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_124_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_124_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_124_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_124_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_124_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_124_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_124_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_125_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_125_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_125_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_125_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_125_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_125_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_125_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_125_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_126_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_126_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_126_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_126_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_126_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_126_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_126_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_126_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_127_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_127_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_127_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_127_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_127_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_127_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_127_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_127_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_128_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_128_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_128_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_128_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_128_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_128_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_128_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_128_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_129_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_129_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_129_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_129_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_129_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_129_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_129_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_129_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_130_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_130_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_130_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_130_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_130_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_130_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_130_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_130_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_131_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_131_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_131_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_131_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_131_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_131_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_131_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_131_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_132_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_132_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_132_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_132_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_132_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_132_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_132_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_132_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_133_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_133_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_133_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_133_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_133_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_133_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_133_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_133_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_134_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_134_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_134_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_134_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_134_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_134_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_134_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_134_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_135_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_135_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_135_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_135_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_135_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_135_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_135_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_135_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_136_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_136_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_136_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_136_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_136_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_136_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_136_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_136_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_137_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_137_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_137_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_137_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_137_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_137_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_137_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_137_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_138_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_138_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_138_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_138_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_138_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_138_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_138_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_138_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_139_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_139_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_139_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_139_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_139_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_139_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_139_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_139_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_140_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_140_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_140_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_140_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_140_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_140_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_140_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_140_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_141_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_141_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_141_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_141_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_141_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_141_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_141_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_141_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_142_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_142_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_142_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_142_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_142_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_142_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_142_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_142_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_143_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_143_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_143_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_143_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_143_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_143_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_143_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_143_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_144_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_144_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_144_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_144_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_144_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_144_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_144_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_144_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_145_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_145_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_145_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_145_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_145_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_145_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_145_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_145_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_146_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_146_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_146_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_146_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_146_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_146_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_146_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_146_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_147_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_147_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_147_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_147_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_147_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_147_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_147_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_147_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_148_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_148_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_148_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_148_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_148_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_148_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_148_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_148_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_149_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_149_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_149_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_149_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_149_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_149_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_149_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_149_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_150_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_150_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_150_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_150_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_150_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_150_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_150_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_150_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_151_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_151_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_151_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_151_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_151_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_151_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_151_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_151_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_152_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_152_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_152_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_152_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_152_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_152_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_152_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_152_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_153_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_153_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_153_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_153_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_153_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_153_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_153_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_153_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_154_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_154_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_154_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_154_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_154_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_154_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_154_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_154_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_155_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_155_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_155_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_155_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_155_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_155_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_155_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_155_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_156_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_156_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_156_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_156_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_156_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_156_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_156_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_156_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_157_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_157_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_157_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_157_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_157_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_157_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_157_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_157_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_158_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_158_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_158_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_158_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_158_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_158_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_158_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_158_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_159_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_159_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_159_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_159_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_159_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_159_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_159_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_159_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_160_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_160_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_160_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_160_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_160_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_160_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_160_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_160_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_161_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_161_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_161_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_161_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_161_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_161_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_161_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_161_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_162_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_162_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_162_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_162_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_162_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_162_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_162_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_162_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_163_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_163_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_163_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_163_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_163_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_163_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_163_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_163_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_164_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_164_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_164_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_164_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_164_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_164_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_164_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_164_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_165_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_165_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_165_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_165_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_165_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_165_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_165_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_165_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_166_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_166_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_166_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_166_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_166_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_166_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_166_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_166_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_167_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_167_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_167_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_167_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_167_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_167_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_167_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_167_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_168_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_168_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_168_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_168_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_168_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_168_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_168_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_168_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_169_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_169_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_169_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_169_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_169_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_169_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_169_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_169_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_170_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_170_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_170_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_170_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_170_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_170_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_170_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_170_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_171_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_171_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_171_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_171_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_171_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_171_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_171_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_171_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_172_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_172_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_172_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_172_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_172_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_172_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_172_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_172_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_173_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_173_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_173_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_173_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_173_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_173_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_173_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_173_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_174_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_174_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_174_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_174_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_174_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_174_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_174_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_174_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_175_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_175_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_175_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_175_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_175_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_175_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_175_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_175_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_176_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_176_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_176_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_176_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_176_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_176_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_176_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_176_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_177_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_177_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_177_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_177_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_177_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_177_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_177_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_177_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_178_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_178_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_178_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_178_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_178_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_178_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_178_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_178_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_179_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_179_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_179_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_179_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_179_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_179_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_179_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_179_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_180_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_180_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_180_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_180_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_180_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_180_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_180_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_180_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_181_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_181_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_181_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_181_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_181_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_181_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_181_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_181_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_182_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_182_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_182_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_182_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_182_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_182_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_182_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_182_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_183_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_183_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_183_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_183_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_183_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_183_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_183_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_183_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_184_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_184_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_184_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_184_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_184_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_184_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_184_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_184_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_185_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_185_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_185_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_185_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_185_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_185_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_185_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_185_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_186_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_186_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_186_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_186_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_186_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_186_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_186_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_186_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_187_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_187_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_187_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_187_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_187_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_187_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_187_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_187_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_188_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_188_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_188_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_188_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_188_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_188_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_188_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_188_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_189_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_189_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_189_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_189_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_189_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_189_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_189_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_189_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_190_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_190_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_190_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_190_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_190_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_190_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_190_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_190_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_191_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_191_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_191_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_191_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_191_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_191_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_191_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_191_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_192_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_192_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_192_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_192_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_192_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_192_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_192_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_192_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_193_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_193_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_193_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_193_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_193_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_193_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_193_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_193_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_194_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_194_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_194_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_194_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_194_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_194_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_194_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_194_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_195_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_195_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_195_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_195_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_195_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_195_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_195_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_195_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_196_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_196_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_196_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_196_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_196_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_196_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_196_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_196_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_197_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_197_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_197_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_197_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_197_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_197_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_197_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_197_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_198_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_198_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_198_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_198_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_198_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_198_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_198_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_198_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_199_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_199_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_199_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_199_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_199_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_199_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_199_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_199_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_200_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_200_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_200_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_200_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_200_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_200_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_200_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_200_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_201_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_201_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_201_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_201_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_201_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_201_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_201_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_201_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_202_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_202_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_202_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_202_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_202_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_202_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_202_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_202_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_203_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_203_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_203_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_203_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_203_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_203_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_203_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_203_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_204_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_204_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_204_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_204_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_204_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_204_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_204_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_204_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_205_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_205_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_205_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_205_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_205_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_205_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_205_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_205_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_206_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_206_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_206_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_206_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_206_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_206_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_206_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_206_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_207_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_207_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_207_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_207_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_207_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_207_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_207_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_207_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_208_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_208_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_208_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_208_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_208_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_208_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_208_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_208_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_209_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_209_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_209_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_209_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_209_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_209_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_209_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_209_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_210_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_210_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_210_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_210_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_210_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_210_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_210_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_210_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_211_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_211_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_211_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_211_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_211_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_211_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_211_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_211_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_212_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_212_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_212_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_212_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_212_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_212_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_212_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_212_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_213_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_213_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_213_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_213_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_213_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_213_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_213_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_213_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_214_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_214_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_214_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_214_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_214_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_214_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_214_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_214_sd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_215_hd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_215_hd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_215_hd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_215_hd.png diff --git a/examples/mods/iron_coast_mission_1/art/tile_215_sd.png b/RustRaptor/mods/iron_coast_mission_1/art/tile_215_sd.png similarity index 100% rename from examples/mods/iron_coast_mission_1/art/tile_215_sd.png rename to RustRaptor/mods/iron_coast_mission_1/art/tile_215_sd.png diff --git a/examples/mods/iron_coast_mission_1/asset-report.json b/RustRaptor/mods/iron_coast_mission_1/asset-report.json similarity index 100% rename from examples/mods/iron_coast_mission_1/asset-report.json rename to RustRaptor/mods/iron_coast_mission_1/asset-report.json diff --git a/examples/mods/iron_coast_mission_1/audio/approach.wav b/RustRaptor/mods/iron_coast_mission_1/audio/approach.wav similarity index 100% rename from examples/mods/iron_coast_mission_1/audio/approach.wav rename to RustRaptor/mods/iron_coast_mission_1/audio/approach.wav diff --git a/examples/mods/iron_coast_mission_1/audio/boss_alarm.wav b/RustRaptor/mods/iron_coast_mission_1/audio/boss_alarm.wav similarity index 100% rename from examples/mods/iron_coast_mission_1/audio/boss_alarm.wav rename to RustRaptor/mods/iron_coast_mission_1/audio/boss_alarm.wav diff --git a/examples/mods/iron_coast_mission_1/audio/depot_open.wav b/RustRaptor/mods/iron_coast_mission_1/audio/depot_open.wav similarity index 100% rename from examples/mods/iron_coast_mission_1/audio/depot_open.wav rename to RustRaptor/mods/iron_coast_mission_1/audio/depot_open.wav diff --git a/examples/mods/iron_coast_mission_1/audio/dock_alarm.wav b/RustRaptor/mods/iron_coast_mission_1/audio/dock_alarm.wav similarity index 100% rename from examples/mods/iron_coast_mission_1/audio/dock_alarm.wav rename to RustRaptor/mods/iron_coast_mission_1/audio/dock_alarm.wav diff --git a/examples/mods/iron_coast_mission_1/audio/repair_use.wav b/RustRaptor/mods/iron_coast_mission_1/audio/repair_use.wav similarity index 100% rename from examples/mods/iron_coast_mission_1/audio/repair_use.wav rename to RustRaptor/mods/iron_coast_mission_1/audio/repair_use.wav diff --git a/examples/mods/iron_coast_mission_1/audio/victory.wav b/RustRaptor/mods/iron_coast_mission_1/audio/victory.wav similarity index 100% rename from examples/mods/iron_coast_mission_1/audio/victory.wav rename to RustRaptor/mods/iron_coast_mission_1/audio/victory.wav diff --git a/examples/mods/iron_coast_mission_1/build_assets.py b/RustRaptor/mods/iron_coast_mission_1/build_assets.py similarity index 100% rename from examples/mods/iron_coast_mission_1/build_assets.py rename to RustRaptor/mods/iron_coast_mission_1/build_assets.py diff --git a/examples/mods/iron_coast_mission_1/content.json b/RustRaptor/mods/iron_coast_mission_1/content.json similarity index 99% rename from examples/mods/iron_coast_mission_1/content.json rename to RustRaptor/mods/iron_coast_mission_1/content.json index 89a0df54..7b83032d 100644 --- a/examples/mods/iron_coast_mission_1/content.json +++ b/RustRaptor/mods/iron_coast_mission_1/content.json @@ -59,7 +59,7 @@ { "id": "iron_coast_mission_1:repair", "name": "Iron Coast Field Repair", - "price": 750, + "price": 12500, "max_count": 5, "initial_count": 1, "special": true, @@ -71,7 +71,8 @@ "logical_height": 16, "frames": 1 }, - "script": "scripts/repair.rhai" + "script": "scripts/repair.rhai", + "description": "Selectable field repair. Use the Special control to restore 25 energy. Each use consumes one kit; carry up to five." } ], "enemies": [ diff --git a/examples/mods/iron_coast_mission_1/mod.toml b/RustRaptor/mods/iron_coast_mission_1/mod.toml similarity index 100% rename from examples/mods/iron_coast_mission_1/mod.toml rename to RustRaptor/mods/iron_coast_mission_1/mod.toml diff --git a/examples/mods/iron_coast_mission_1/prompts.json b/RustRaptor/mods/iron_coast_mission_1/prompts.json similarity index 100% rename from examples/mods/iron_coast_mission_1/prompts.json rename to RustRaptor/mods/iron_coast_mission_1/prompts.json diff --git a/examples/mods/iron_coast_mission_1/prompts.md b/RustRaptor/mods/iron_coast_mission_1/prompts.md similarity index 100% rename from examples/mods/iron_coast_mission_1/prompts.md rename to RustRaptor/mods/iron_coast_mission_1/prompts.md diff --git a/examples/mods/iron_coast_mission_1/scripts/depot.rhai b/RustRaptor/mods/iron_coast_mission_1/scripts/depot.rhai similarity index 100% rename from examples/mods/iron_coast_mission_1/scripts/depot.rhai rename to RustRaptor/mods/iron_coast_mission_1/scripts/depot.rhai diff --git a/examples/mods/iron_coast_mission_1/scripts/mission.rhai b/RustRaptor/mods/iron_coast_mission_1/scripts/mission.rhai similarity index 76% rename from examples/mods/iron_coast_mission_1/scripts/mission.rhai rename to RustRaptor/mods/iron_coast_mission_1/scripts/mission.rhai index 2da4f547..a6741b0a 100644 --- a/examples/mods/iron_coast_mission_1/scripts/mission.rhai +++ b/RustRaptor/mods/iron_coast_mission_1/scripts/mission.rhai @@ -7,18 +7,24 @@ fn on_mission_start(event, state, random) { ]; // A full veteran inventory must not prevent a mission from starting. // Existing equipment may receive its normal refill without adding a slot. - let slots=event.player.inventory.len(); + let slots=event.player.inventory.len()+event.player.super_shield_reserves.len(); if event.player.inventory.contains("iron_coast_mission_1:repair") || slots<20 { commands.push(#{op:"give_item",item:"iron_coast_mission_1:repair",count:3}); if !event.player.inventory.contains("iron_coast_mission_1:repair") {slots+=1;} } let trial=0; - if event.player.inventory.contains("pulse_cannon:equipment") || slots<20 { + // Borrow a cannon for this mission only. Never replace or remove an owned copy. + let trial_loaned=false; + if event.player.inventory.contains("pulse_cannon:equipment") + && event.player.inventory["pulse_cannon:equipment"]>0 { + trial=1; + } else if slots<20 { commands.push(#{op:"give_item",item:"pulse_cannon:equipment",count:1}); trial=1; + trial_loaned=true; } commands.push(#{op:"objective",key:"iron_coast_mission_1:trial_issued",value:trial}); - #{commands:commands,state:#{dock_alarm:false,boss_alarm:false,complete:false}} + #{commands:commands,state:#{dock_alarm:false,boss_alarm:false,complete:false,trial_loaned:trial_loaned}} } fn on_tick(event, state, random) { @@ -56,5 +62,12 @@ fn on_tick(event, state, random) { fn on_mission_end(event,state,random) { let value=0; if event.success {value=1;} - #{commands:[#{op:"objective",key:"iron_coast_mission_1:debrief",value:value}],state:state} + let commands=[#{op:"objective",key:"iron_coast_mission_1:debrief",value:value}]; + if state.contains("trial_loaned") && state.trial_loaned + && event.player.inventory.contains("pulse_cannon:equipment") + && event.player.inventory["pulse_cannon:equipment"]>0 { + commands.push(#{op:"consume_item",item:"pulse_cannon:equipment",count:1}); + } + state.trial_loaned=false; + #{commands:commands,state:state} } diff --git a/examples/mods/iron_coast_mission_1/scripts/repair.rhai b/RustRaptor/mods/iron_coast_mission_1/scripts/repair.rhai similarity index 100% rename from examples/mods/iron_coast_mission_1/scripts/repair.rhai rename to RustRaptor/mods/iron_coast_mission_1/scripts/repair.rhai diff --git a/examples/mods/iron_coast_scout/README.md b/RustRaptor/mods/iron_coast_scout/README.md similarity index 88% rename from examples/mods/iron_coast_scout/README.md rename to RustRaptor/mods/iron_coast_scout/README.md index 723c4831..3e5898e6 100644 --- a/examples/mods/iron_coast_scout/README.md +++ b/RustRaptor/mods/iron_coast_scout/README.md @@ -14,4 +14,4 @@ Generated PNG masters, SD/HD strips, previews and WAV sounds are included in Git To rebuild derived artwork from supplied generated masters and regenerate audio, install Pillow and run `python3 build_assets.py`. To regenerate only audio with the Python standard library, run `python3 build_assets.py --audio-only`. WAVs are original procedural mono 16-bit PCM at 22050 Hz; `audio-recipe.json` records deterministic seeds, sizes and hashes. The art pipeline resizes and assembles supplied images; it does not redraw them. -Validate from the repository root: `cargo run -- --mods examples/mods --validate-mods iron_coast_scout`. Synthetic behavior and save/replay tests: `cargo test --test enemies_mods`. Verify shipped artwork locally: `cargo test --test enemies_mods shipped_enemy_assets_are_valid_and_aligned`. +Validate from the repository root: `cargo run -- --mods mods --validate-mods iron_coast_scout`. Synthetic behavior and save/replay tests: `cargo test --test enemies_mods`. Verify shipped artwork locally: `cargo test --test enemies_mods shipped_enemy_assets_are_valid_and_aligned`. diff --git a/examples/mods/iron_coast_scout/art-qa.json b/RustRaptor/mods/iron_coast_scout/art-qa.json similarity index 100% rename from examples/mods/iron_coast_scout/art-qa.json rename to RustRaptor/mods/iron_coast_scout/art-qa.json diff --git a/examples/mods/iron_coast_scout/art/preview.png b/RustRaptor/mods/iron_coast_scout/art/preview.png similarity index 100% rename from examples/mods/iron_coast_scout/art/preview.png rename to RustRaptor/mods/iron_coast_scout/art/preview.png diff --git a/examples/mods/iron_coast_scout/art/projectile-generated-v1.png b/RustRaptor/mods/iron_coast_scout/art/projectile-generated-v1.png similarity index 100% rename from examples/mods/iron_coast_scout/art/projectile-generated-v1.png rename to RustRaptor/mods/iron_coast_scout/art/projectile-generated-v1.png diff --git a/examples/mods/iron_coast_scout/art/projectile-hd.png b/RustRaptor/mods/iron_coast_scout/art/projectile-hd.png similarity index 100% rename from examples/mods/iron_coast_scout/art/projectile-hd.png rename to RustRaptor/mods/iron_coast_scout/art/projectile-hd.png diff --git a/examples/mods/iron_coast_scout/art/projectile-sd.png b/RustRaptor/mods/iron_coast_scout/art/projectile-sd.png similarity index 100% rename from examples/mods/iron_coast_scout/art/projectile-sd.png rename to RustRaptor/mods/iron_coast_scout/art/projectile-sd.png diff --git a/examples/mods/iron_coast_scout/art/unit-generated-v1.png b/RustRaptor/mods/iron_coast_scout/art/unit-generated-v1.png similarity index 100% rename from examples/mods/iron_coast_scout/art/unit-generated-v1.png rename to RustRaptor/mods/iron_coast_scout/art/unit-generated-v1.png diff --git a/examples/mods/iron_coast_scout/art/unit-hd.png b/RustRaptor/mods/iron_coast_scout/art/unit-hd.png similarity index 100% rename from examples/mods/iron_coast_scout/art/unit-hd.png rename to RustRaptor/mods/iron_coast_scout/art/unit-hd.png diff --git a/examples/mods/iron_coast_scout/art/unit-sd.png b/RustRaptor/mods/iron_coast_scout/art/unit-sd.png similarity index 100% rename from examples/mods/iron_coast_scout/art/unit-sd.png rename to RustRaptor/mods/iron_coast_scout/art/unit-sd.png diff --git a/examples/mods/iron_coast_scout/audio-recipe.json b/RustRaptor/mods/iron_coast_scout/audio-recipe.json similarity index 100% rename from examples/mods/iron_coast_scout/audio-recipe.json rename to RustRaptor/mods/iron_coast_scout/audio-recipe.json diff --git a/examples/mods/iron_coast_scout/audio/death.wav b/RustRaptor/mods/iron_coast_scout/audio/death.wav similarity index 100% rename from examples/mods/iron_coast_scout/audio/death.wav rename to RustRaptor/mods/iron_coast_scout/audio/death.wav diff --git a/examples/mods/iron_coast_scout/audio/shot.wav b/RustRaptor/mods/iron_coast_scout/audio/shot.wav similarity index 100% rename from examples/mods/iron_coast_scout/audio/shot.wav rename to RustRaptor/mods/iron_coast_scout/audio/shot.wav diff --git a/examples/mods/iron_coast_turret/build_assets.py b/RustRaptor/mods/iron_coast_scout/build_assets.py similarity index 97% rename from examples/mods/iron_coast_turret/build_assets.py rename to RustRaptor/mods/iron_coast_scout/build_assets.py index 496fcc88..46ef58ef 100644 --- a/examples/mods/iron_coast_turret/build_assets.py +++ b/RustRaptor/mods/iron_coast_scout/build_assets.py @@ -2,8 +2,8 @@ """Convert saved imagegen artwork and synthesize reproducible package-local WAVs. This script resizes/atlases supplied art; it never redraws source or generated art. -Generated source PNGs stay in ignored art/. See prompts/ and provenance.json. -Use --audio-only to regenerate sound without Pillow or private artwork. +Generated masters, SD/HD artwork and WAVs are tracked in Git. See prompts/ and provenance.json. +Use --audio-only to regenerate sound without Pillow or processing artwork. """ import argparse import hashlib diff --git a/examples/mods/iron_coast_scout/content.json b/RustRaptor/mods/iron_coast_scout/content.json similarity index 100% rename from examples/mods/iron_coast_scout/content.json rename to RustRaptor/mods/iron_coast_scout/content.json diff --git a/examples/mods/iron_coast_scout/mod.toml b/RustRaptor/mods/iron_coast_scout/mod.toml similarity index 100% rename from examples/mods/iron_coast_scout/mod.toml rename to RustRaptor/mods/iron_coast_scout/mod.toml diff --git a/examples/mods/iron_coast_scout/prompts/projectile-hd-v1.txt b/RustRaptor/mods/iron_coast_scout/prompts/projectile-hd-v1.txt similarity index 100% rename from examples/mods/iron_coast_scout/prompts/projectile-hd-v1.txt rename to RustRaptor/mods/iron_coast_scout/prompts/projectile-hd-v1.txt diff --git a/examples/mods/iron_coast_scout/prompts/unit-hd-v1.txt b/RustRaptor/mods/iron_coast_scout/prompts/unit-hd-v1.txt similarity index 100% rename from examples/mods/iron_coast_scout/prompts/unit-hd-v1.txt rename to RustRaptor/mods/iron_coast_scout/prompts/unit-hd-v1.txt diff --git a/examples/mods/iron_coast_scout/provenance.json b/RustRaptor/mods/iron_coast_scout/provenance.json similarity index 100% rename from examples/mods/iron_coast_scout/provenance.json rename to RustRaptor/mods/iron_coast_scout/provenance.json diff --git a/examples/mods/iron_coast_scout/scripts/ai.rhai b/RustRaptor/mods/iron_coast_scout/scripts/ai.rhai similarity index 100% rename from examples/mods/iron_coast_scout/scripts/ai.rhai rename to RustRaptor/mods/iron_coast_scout/scripts/ai.rhai diff --git a/examples/mods/iron_coast_turret/README.md b/RustRaptor/mods/iron_coast_turret/README.md similarity index 88% rename from examples/mods/iron_coast_turret/README.md rename to RustRaptor/mods/iron_coast_turret/README.md index 53bd7992..3008fef7 100644 --- a/examples/mods/iron_coast_turret/README.md +++ b/RustRaptor/mods/iron_coast_turret/README.md @@ -14,4 +14,4 @@ Generated PNG masters, SD/HD strips, previews and WAV sounds are included in Git To rebuild derived artwork from supplied generated masters and regenerate audio, install Pillow and run `python3 build_assets.py`. To regenerate only audio with the Python standard library, run `python3 build_assets.py --audio-only`. WAVs are original procedural mono 16-bit PCM at 22050 Hz; `audio-recipe.json` records deterministic seeds, sizes and hashes. The art pipeline resizes and assembles supplied images; it does not redraw them. -Validate from the repository root: `cargo run -- --mods examples/mods --validate-mods iron_coast_turret`. Synthetic behavior and save/replay tests: `cargo test --test enemies_mods`. Verify shipped artwork locally: `cargo test --test enemies_mods shipped_enemy_assets_are_valid_and_aligned`. +Validate from the repository root: `cargo run -- --mods mods --validate-mods iron_coast_turret`. Synthetic behavior and save/replay tests: `cargo test --test enemies_mods`. Verify shipped artwork locally: `cargo test --test enemies_mods shipped_enemy_assets_are_valid_and_aligned`. diff --git a/examples/mods/iron_coast_turret/art-qa.json b/RustRaptor/mods/iron_coast_turret/art-qa.json similarity index 100% rename from examples/mods/iron_coast_turret/art-qa.json rename to RustRaptor/mods/iron_coast_turret/art-qa.json diff --git a/examples/mods/iron_coast_turret/art/preview.png b/RustRaptor/mods/iron_coast_turret/art/preview.png similarity index 100% rename from examples/mods/iron_coast_turret/art/preview.png rename to RustRaptor/mods/iron_coast_turret/art/preview.png diff --git a/examples/mods/iron_coast_turret/art/projectile-generated-v1.png b/RustRaptor/mods/iron_coast_turret/art/projectile-generated-v1.png similarity index 100% rename from examples/mods/iron_coast_turret/art/projectile-generated-v1.png rename to RustRaptor/mods/iron_coast_turret/art/projectile-generated-v1.png diff --git a/examples/mods/iron_coast_turret/art/projectile-hd.png b/RustRaptor/mods/iron_coast_turret/art/projectile-hd.png similarity index 100% rename from examples/mods/iron_coast_turret/art/projectile-hd.png rename to RustRaptor/mods/iron_coast_turret/art/projectile-hd.png diff --git a/examples/mods/iron_coast_turret/art/projectile-sd.png b/RustRaptor/mods/iron_coast_turret/art/projectile-sd.png similarity index 100% rename from examples/mods/iron_coast_turret/art/projectile-sd.png rename to RustRaptor/mods/iron_coast_turret/art/projectile-sd.png diff --git a/examples/mods/iron_coast_turret/art/unit-generated-v1.png b/RustRaptor/mods/iron_coast_turret/art/unit-generated-v1.png similarity index 100% rename from examples/mods/iron_coast_turret/art/unit-generated-v1.png rename to RustRaptor/mods/iron_coast_turret/art/unit-generated-v1.png diff --git a/examples/mods/iron_coast_turret/art/unit-hd.png b/RustRaptor/mods/iron_coast_turret/art/unit-hd.png similarity index 100% rename from examples/mods/iron_coast_turret/art/unit-hd.png rename to RustRaptor/mods/iron_coast_turret/art/unit-hd.png diff --git a/examples/mods/iron_coast_turret/art/unit-sd.png b/RustRaptor/mods/iron_coast_turret/art/unit-sd.png similarity index 100% rename from examples/mods/iron_coast_turret/art/unit-sd.png rename to RustRaptor/mods/iron_coast_turret/art/unit-sd.png diff --git a/examples/mods/iron_coast_turret/audio-recipe.json b/RustRaptor/mods/iron_coast_turret/audio-recipe.json similarity index 100% rename from examples/mods/iron_coast_turret/audio-recipe.json rename to RustRaptor/mods/iron_coast_turret/audio-recipe.json diff --git a/examples/mods/iron_coast_turret/audio/death.wav b/RustRaptor/mods/iron_coast_turret/audio/death.wav similarity index 100% rename from examples/mods/iron_coast_turret/audio/death.wav rename to RustRaptor/mods/iron_coast_turret/audio/death.wav diff --git a/examples/mods/iron_coast_turret/audio/shot.wav b/RustRaptor/mods/iron_coast_turret/audio/shot.wav similarity index 100% rename from examples/mods/iron_coast_turret/audio/shot.wav rename to RustRaptor/mods/iron_coast_turret/audio/shot.wav diff --git a/examples/mods/iron_coast_scout/build_assets.py b/RustRaptor/mods/iron_coast_turret/build_assets.py similarity index 97% rename from examples/mods/iron_coast_scout/build_assets.py rename to RustRaptor/mods/iron_coast_turret/build_assets.py index 496fcc88..46ef58ef 100644 --- a/examples/mods/iron_coast_scout/build_assets.py +++ b/RustRaptor/mods/iron_coast_turret/build_assets.py @@ -2,8 +2,8 @@ """Convert saved imagegen artwork and synthesize reproducible package-local WAVs. This script resizes/atlases supplied art; it never redraws source or generated art. -Generated source PNGs stay in ignored art/. See prompts/ and provenance.json. -Use --audio-only to regenerate sound without Pillow or private artwork. +Generated masters, SD/HD artwork and WAVs are tracked in Git. See prompts/ and provenance.json. +Use --audio-only to regenerate sound without Pillow or processing artwork. """ import argparse import hashlib diff --git a/examples/mods/iron_coast_turret/content.json b/RustRaptor/mods/iron_coast_turret/content.json similarity index 100% rename from examples/mods/iron_coast_turret/content.json rename to RustRaptor/mods/iron_coast_turret/content.json diff --git a/examples/mods/iron_coast_turret/mod.toml b/RustRaptor/mods/iron_coast_turret/mod.toml similarity index 100% rename from examples/mods/iron_coast_turret/mod.toml rename to RustRaptor/mods/iron_coast_turret/mod.toml diff --git a/examples/mods/iron_coast_turret/prompts/projectile-hd-v1.txt b/RustRaptor/mods/iron_coast_turret/prompts/projectile-hd-v1.txt similarity index 100% rename from examples/mods/iron_coast_turret/prompts/projectile-hd-v1.txt rename to RustRaptor/mods/iron_coast_turret/prompts/projectile-hd-v1.txt diff --git a/examples/mods/iron_coast_turret/prompts/unit-hd-v1.txt b/RustRaptor/mods/iron_coast_turret/prompts/unit-hd-v1.txt similarity index 100% rename from examples/mods/iron_coast_turret/prompts/unit-hd-v1.txt rename to RustRaptor/mods/iron_coast_turret/prompts/unit-hd-v1.txt diff --git a/examples/mods/iron_coast_turret/provenance.json b/RustRaptor/mods/iron_coast_turret/provenance.json similarity index 100% rename from examples/mods/iron_coast_turret/provenance.json rename to RustRaptor/mods/iron_coast_turret/provenance.json diff --git a/examples/mods/iron_coast_turret/scripts/ai.rhai b/RustRaptor/mods/iron_coast_turret/scripts/ai.rhai similarity index 100% rename from examples/mods/iron_coast_turret/scripts/ai.rhai rename to RustRaptor/mods/iron_coast_turret/scripts/ai.rhai diff --git a/examples/mods/pulse_cannon/ART_PROVENANCE.md b/RustRaptor/mods/pulse_cannon/ART_PROVENANCE.md similarity index 100% rename from examples/mods/pulse_cannon/ART_PROVENANCE.md rename to RustRaptor/mods/pulse_cannon/ART_PROVENANCE.md diff --git a/examples/mods/pulse_cannon/README.md b/RustRaptor/mods/pulse_cannon/README.md similarity index 76% rename from examples/mods/pulse_cannon/README.md rename to RustRaptor/mods/pulse_cannon/README.md index 28678dc2..f9dd8ce4 100644 --- a/examples/mods/pulse_cannon/README.md +++ b/RustRaptor/mods/pulse_cannon/README.md @@ -1,9 +1,9 @@ # Pulse Cannon 1.0.0 -Enable **Pulse Cannon** in Mods and apply at the main menu. This independent API 1 example has no dependencies. Buy its equipment in the shop (6,500 credits, maximum one) or collect its pickup. It is a primary upgrade and fires alongside the starter guns. +Enable **Pulse Cannon** in Mods and apply at the main menu. This independent API 1 example has no dependencies. Buy its equipment in the shop (185,000 credits, maximum one) or collect its pickup. It is a primary upgrade and fires alongside the starter guns. Projectile: 4 damage, 8 logical pixels per step, 9 step cooldown, `straight`, air and ground targets. Every third paired burst adds two angled shots using deterministic random values. Rhai stores bounded burst/hit counters and emits local firing/impact sounds and effects. `art/equipment-sd.png` is a 16×16 logical icon, `art/equipment-hd.png` its detailed 4× variant. Projectile canvases are 8×12 SD and 32×48 HD. Switch HD artwork in Options. The generated master images, SD/HD sprites, WAV files, provenance and reproduction inputs are tracked in Git. Original game assets and decoded reference images remain outside version control. `python3 generate_audio.py` reproduces both sound files. -Validate locally with `cargo run --no-default-features -- --mods examples/mods --validate-mods pulse_cannon`. Read the shared [API documentation](../../../docs/modding-api.md) for save pins, dependencies and sandbox limits. +Validate locally with `cargo run --no-default-features -- --mods mods --validate-mods pulse_cannon`. Read the shared [API documentation](../../docs/modding-api.md) for save pins, dependencies and sandbox limits. diff --git a/examples/mods/pulse_cannon/art-provenance.json b/RustRaptor/mods/pulse_cannon/art-provenance.json similarity index 100% rename from examples/mods/pulse_cannon/art-provenance.json rename to RustRaptor/mods/pulse_cannon/art-provenance.json diff --git a/examples/mods/pulse_cannon/art/equipment-generated-source.png b/RustRaptor/mods/pulse_cannon/art/equipment-generated-source.png similarity index 100% rename from examples/mods/pulse_cannon/art/equipment-generated-source.png rename to RustRaptor/mods/pulse_cannon/art/equipment-generated-source.png diff --git a/examples/mods/pulse_cannon/art/equipment-hd.png b/RustRaptor/mods/pulse_cannon/art/equipment-hd.png similarity index 100% rename from examples/mods/pulse_cannon/art/equipment-hd.png rename to RustRaptor/mods/pulse_cannon/art/equipment-hd.png diff --git a/examples/mods/pulse_cannon/art/equipment-sd.png b/RustRaptor/mods/pulse_cannon/art/equipment-sd.png similarity index 100% rename from examples/mods/pulse_cannon/art/equipment-sd.png rename to RustRaptor/mods/pulse_cannon/art/equipment-sd.png diff --git a/examples/mods/pulse_cannon/art/projectile-generated-source.png b/RustRaptor/mods/pulse_cannon/art/projectile-generated-source.png similarity index 100% rename from examples/mods/pulse_cannon/art/projectile-generated-source.png rename to RustRaptor/mods/pulse_cannon/art/projectile-generated-source.png diff --git a/examples/mods/pulse_cannon/art/projectile-hd.png b/RustRaptor/mods/pulse_cannon/art/projectile-hd.png similarity index 100% rename from examples/mods/pulse_cannon/art/projectile-hd.png rename to RustRaptor/mods/pulse_cannon/art/projectile-hd.png diff --git a/examples/mods/pulse_cannon/art/projectile-sd.png b/RustRaptor/mods/pulse_cannon/art/projectile-sd.png similarity index 100% rename from examples/mods/pulse_cannon/art/projectile-sd.png rename to RustRaptor/mods/pulse_cannon/art/projectile-sd.png diff --git a/examples/mods/pulse_cannon/audio/fire.wav b/RustRaptor/mods/pulse_cannon/audio/fire.wav similarity index 100% rename from examples/mods/pulse_cannon/audio/fire.wav rename to RustRaptor/mods/pulse_cannon/audio/fire.wav diff --git a/examples/mods/pulse_cannon/audio/impact.wav b/RustRaptor/mods/pulse_cannon/audio/impact.wav similarity index 100% rename from examples/mods/pulse_cannon/audio/impact.wav rename to RustRaptor/mods/pulse_cannon/audio/impact.wav diff --git a/examples/mods/pulse_cannon/content.json b/RustRaptor/mods/pulse_cannon/content.json similarity index 83% rename from examples/mods/pulse_cannon/content.json rename to RustRaptor/mods/pulse_cannon/content.json index aa320c79..f1797eaf 100644 --- a/examples/mods/pulse_cannon/content.json +++ b/RustRaptor/mods/pulse_cannon/content.json @@ -32,7 +32,7 @@ { "id": "pulse_cannon:equipment", "name": "Pulse Cannon", - "price": 6500, + "price": 185000, "max_count": 1, "initial_count": 1, "weapon": "pulse_cannon:projectile", @@ -45,7 +45,8 @@ "logical_height": 16, "nearest": true }, - "script": "scripts/equipment.rhai" + "script": "scripts/equipment.rhai", + "description": "Always-equipped primary upgrade. Fires paired pulses at air and ground targets alongside your other primary guns. Every third burst adds two angled shots. Hold Fire; no ammunition is consumed." } ], "sounds": [ diff --git a/examples/mods/pulse_cannon/generate_audio.py b/RustRaptor/mods/pulse_cannon/generate_audio.py similarity index 100% rename from examples/mods/pulse_cannon/generate_audio.py rename to RustRaptor/mods/pulse_cannon/generate_audio.py diff --git a/examples/mods/pulse_cannon/mod.toml b/RustRaptor/mods/pulse_cannon/mod.toml similarity index 100% rename from examples/mods/pulse_cannon/mod.toml rename to RustRaptor/mods/pulse_cannon/mod.toml diff --git a/examples/mods/pulse_cannon/prepare_art.rs b/RustRaptor/mods/pulse_cannon/prepare_art.rs similarity index 100% rename from examples/mods/pulse_cannon/prepare_art.rs rename to RustRaptor/mods/pulse_cannon/prepare_art.rs diff --git a/examples/mods/pulse_cannon/scripts/equipment.rhai b/RustRaptor/mods/pulse_cannon/scripts/equipment.rhai similarity index 100% rename from examples/mods/pulse_cannon/scripts/equipment.rhai rename to RustRaptor/mods/pulse_cannon/scripts/equipment.rhai diff --git a/examples/mods/pulse_cannon/scripts/weapon.rhai b/RustRaptor/mods/pulse_cannon/scripts/weapon.rhai similarity index 86% rename from examples/mods/pulse_cannon/scripts/weapon.rhai rename to RustRaptor/mods/pulse_cannon/scripts/weapon.rhai index f6927794..2f6269c0 100644 --- a/examples/mods/pulse_cannon/scripts/weapon.rhai +++ b/RustRaptor/mods/pulse_cannon/scripts/weapon.rhai @@ -1,11 +1,10 @@ // Built-in straight projectiles handle the normal volley and collision rules. -// Callbacks add deterministic visuals, audio and serializable weapon counters. +// Firing callbacks add audio and counters; impact callbacks add hit visuals. fn on_fire(event, state, random) { let bursts = if state.contains("bursts") { state.bursts % 100000 + 1 } else { 1 }; let hits = if state.contains("hits") { state.hits } else { 0 }; let commands = [ - #{ op: "play_sound", sound: "pulse_cannon:fire" }, -#{ op: "effect", x: event.x, y: event.y - 3 } + #{ op: "play_sound", sound: "pulse_cannon:fire" } ]; // Every third burst adds a deterministic pair of shallow angled pulses. diff --git a/examples/mods/seeker_swarm/ART_PROVENANCE.md b/RustRaptor/mods/seeker_swarm/ART_PROVENANCE.md similarity index 100% rename from examples/mods/seeker_swarm/ART_PROVENANCE.md rename to RustRaptor/mods/seeker_swarm/ART_PROVENANCE.md diff --git a/examples/mods/seeker_swarm/README.md b/RustRaptor/mods/seeker_swarm/README.md similarity index 71% rename from examples/mods/seeker_swarm/README.md rename to RustRaptor/mods/seeker_swarm/README.md index 0060c8bb..83b90432 100644 --- a/examples/mods/seeker_swarm/README.md +++ b/RustRaptor/mods/seeker_swarm/README.md @@ -1,9 +1,9 @@ # Seeker Swarm 1.0.0 -Enable **Seeker Swarm** in Mods and apply at the main menu. This independent API 1 example has no dependencies. Buy its equipment in the shop (14,000 credits, maximum one) or collect its pickup. Choose it in Inventory or cycle special weapons during a mission. The equipped weapon fires with Fire or Special; it consumes no ammunition. +Enable **Seeker Swarm** in Mods and apply at the main menu. This independent API 1 example has no dependencies. Buy its equipment in the shop (285,000 credits, maximum one) or collect its pickup. Choose it in Inventory or cycle special weapons during a mission. The equipped weapon fires with Fire or Special; it consumes no ammunition. Projectile: 6 damage, 6 logical pixels per step, 18 step cooldown, `homing`, air and ground targets. Three homing missiles fire together; an impact deals two fragmentation damage to up to two nearby enemies. Rhai stores bounded burst/hit counters and emits local firing/impact sounds and effects. `art/equipment-sd.png` is a 16×16 logical icon, `art/equipment-hd.png` its detailed 4× variant. Projectile canvases are 8×16 SD and 32×64 HD. Switch HD artwork in Options. The generated master images, SD/HD sprites, WAV files, provenance and reproduction inputs are tracked in Git. Original game assets and decoded reference images remain outside version control. `python3 generate_audio.py` reproduces both sound files. -Validate locally with `cargo run --no-default-features -- --mods examples/mods --validate-mods seeker_swarm`. Read the shared [API documentation](../../../docs/modding-api.md) for save pins, dependencies and sandbox limits. +Validate locally with `cargo run --no-default-features -- --mods mods --validate-mods seeker_swarm`. Read the shared [API documentation](../../docs/modding-api.md) for save pins, dependencies and sandbox limits. diff --git a/examples/mods/seeker_swarm/art-provenance.json b/RustRaptor/mods/seeker_swarm/art-provenance.json similarity index 100% rename from examples/mods/seeker_swarm/art-provenance.json rename to RustRaptor/mods/seeker_swarm/art-provenance.json diff --git a/examples/mods/seeker_swarm/art/equipment-generated-source.png b/RustRaptor/mods/seeker_swarm/art/equipment-generated-source.png similarity index 100% rename from examples/mods/seeker_swarm/art/equipment-generated-source.png rename to RustRaptor/mods/seeker_swarm/art/equipment-generated-source.png diff --git a/examples/mods/seeker_swarm/art/equipment-hd.png b/RustRaptor/mods/seeker_swarm/art/equipment-hd.png similarity index 100% rename from examples/mods/seeker_swarm/art/equipment-hd.png rename to RustRaptor/mods/seeker_swarm/art/equipment-hd.png diff --git a/examples/mods/seeker_swarm/art/equipment-sd.png b/RustRaptor/mods/seeker_swarm/art/equipment-sd.png similarity index 100% rename from examples/mods/seeker_swarm/art/equipment-sd.png rename to RustRaptor/mods/seeker_swarm/art/equipment-sd.png diff --git a/examples/mods/seeker_swarm/art/projectile-generated-source.png b/RustRaptor/mods/seeker_swarm/art/projectile-generated-source.png similarity index 100% rename from examples/mods/seeker_swarm/art/projectile-generated-source.png rename to RustRaptor/mods/seeker_swarm/art/projectile-generated-source.png diff --git a/examples/mods/seeker_swarm/art/projectile-hd.png b/RustRaptor/mods/seeker_swarm/art/projectile-hd.png similarity index 100% rename from examples/mods/seeker_swarm/art/projectile-hd.png rename to RustRaptor/mods/seeker_swarm/art/projectile-hd.png diff --git a/examples/mods/seeker_swarm/art/projectile-sd.png b/RustRaptor/mods/seeker_swarm/art/projectile-sd.png similarity index 100% rename from examples/mods/seeker_swarm/art/projectile-sd.png rename to RustRaptor/mods/seeker_swarm/art/projectile-sd.png diff --git a/examples/mods/seeker_swarm/audio/fire.wav b/RustRaptor/mods/seeker_swarm/audio/fire.wav similarity index 100% rename from examples/mods/seeker_swarm/audio/fire.wav rename to RustRaptor/mods/seeker_swarm/audio/fire.wav diff --git a/examples/mods/seeker_swarm/audio/impact.wav b/RustRaptor/mods/seeker_swarm/audio/impact.wav similarity index 100% rename from examples/mods/seeker_swarm/audio/impact.wav rename to RustRaptor/mods/seeker_swarm/audio/impact.wav diff --git a/examples/mods/seeker_swarm/content.json b/RustRaptor/mods/seeker_swarm/content.json similarity index 83% rename from examples/mods/seeker_swarm/content.json rename to RustRaptor/mods/seeker_swarm/content.json index 0f1fce81..e1bfdd06 100644 --- a/examples/mods/seeker_swarm/content.json +++ b/RustRaptor/mods/seeker_swarm/content.json @@ -36,7 +36,7 @@ { "id": "seeker_swarm:equipment", "name": "Seeker Swarm", - "price": 14000, + "price": 285000, "max_count": 1, "initial_count": 1, "weapon": "seeker_swarm:projectile", @@ -49,7 +49,8 @@ "logical_height": 16, "nearest": true }, - "script": "scripts/equipment.rhai" + "script": "scripts/equipment.rhai", + "description": "Selectable reusable special. Fires three homing missiles at air or ground enemies. Hits scatter fragments into up to two nearby enemies. Select it in Inventory or cycle specials, then use Fire or Special." } ], "sounds": [ diff --git a/examples/mods/seeker_swarm/generate_audio.py b/RustRaptor/mods/seeker_swarm/generate_audio.py similarity index 100% rename from examples/mods/seeker_swarm/generate_audio.py rename to RustRaptor/mods/seeker_swarm/generate_audio.py diff --git a/examples/mods/seeker_swarm/mod.toml b/RustRaptor/mods/seeker_swarm/mod.toml similarity index 100% rename from examples/mods/seeker_swarm/mod.toml rename to RustRaptor/mods/seeker_swarm/mod.toml diff --git a/examples/mods/seeker_swarm/prepare_art.rs b/RustRaptor/mods/seeker_swarm/prepare_art.rs similarity index 100% rename from examples/mods/seeker_swarm/prepare_art.rs rename to RustRaptor/mods/seeker_swarm/prepare_art.rs diff --git a/examples/mods/seeker_swarm/scripts/equipment.rhai b/RustRaptor/mods/seeker_swarm/scripts/equipment.rhai similarity index 100% rename from examples/mods/seeker_swarm/scripts/equipment.rhai rename to RustRaptor/mods/seeker_swarm/scripts/equipment.rhai diff --git a/examples/mods/seeker_swarm/scripts/weapon.rhai b/RustRaptor/mods/seeker_swarm/scripts/weapon.rhai similarity index 86% rename from examples/mods/seeker_swarm/scripts/weapon.rhai rename to RustRaptor/mods/seeker_swarm/scripts/weapon.rhai index 781239a9..262c8519 100644 --- a/examples/mods/seeker_swarm/scripts/weapon.rhai +++ b/RustRaptor/mods/seeker_swarm/scripts/weapon.rhai @@ -1,11 +1,10 @@ // Built-in homing projectiles handle the normal volley and collision rules. -// Callbacks add deterministic visuals, audio and serializable weapon counters. +// Firing callbacks add audio and counters; impact callbacks add hit visuals. fn on_fire(event, state, random) { let bursts = if state.contains("bursts") { state.bursts % 100000 + 1 } else { 1 }; let hits = if state.contains("hits") { state.hits } else { 0 }; let commands = [ - #{ op: "play_sound", sound: "seeker_swarm:fire" }, -#{ op: "effect", x: event.x, y: event.y - 3 } + #{ op: "play_sound", sound: "seeker_swarm:fire" } ]; #{ commands: commands, state: #{ bursts: bursts, hits: hits } } } diff --git a/RustRaptor/run.sh b/RustRaptor/run.sh new file mode 100755 index 00000000..25026cfd --- /dev/null +++ b/RustRaptor/run.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")" +export LD_LIBRARY_PATH="${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/schemas/campaign-v1.json b/RustRaptor/schemas/campaign-v1.json similarity index 100% rename from schemas/campaign-v1.json rename to RustRaptor/schemas/campaign-v1.json diff --git a/schemas/content-v1.json b/RustRaptor/schemas/content-v1.json similarity index 98% rename from schemas/content-v1.json rename to RustRaptor/schemas/content-v1.json index 9014be1a..a5d8f165 100644 --- a/schemas/content-v1.json +++ b/RustRaptor/schemas/content-v1.json @@ -264,6 +264,11 @@ "format": "uint32", "minimum": 0.0 }, + "description": { + "description": "Optional shop information, up to 4096 UTF-8 bytes.", + "default": "", + "type": "string" + }, "id": { "type": "string" }, diff --git a/schemas/enemy-v1.json b/RustRaptor/schemas/enemy-v1.json similarity index 100% rename from schemas/enemy-v1.json rename to RustRaptor/schemas/enemy-v1.json diff --git a/schemas/hook-v1.json b/RustRaptor/schemas/hook-v1.json similarity index 100% rename from schemas/hook-v1.json rename to RustRaptor/schemas/hook-v1.json diff --git a/schemas/item-v1.json b/RustRaptor/schemas/item-v1.json similarity index 94% rename from schemas/item-v1.json rename to RustRaptor/schemas/item-v1.json index d254e481..51c6d5ad 100644 --- a/schemas/item-v1.json +++ b/RustRaptor/schemas/item-v1.json @@ -29,6 +29,11 @@ "format": "uint32", "minimum": 0.0 }, + "description": { + "description": "Optional shop information, up to 4096 UTF-8 bytes.", + "default": "", + "type": "string" + }, "id": { "type": "string" }, diff --git a/schemas/mission-v1.json b/RustRaptor/schemas/mission-v1.json similarity index 100% rename from schemas/mission-v1.json rename to RustRaptor/schemas/mission-v1.json diff --git a/schemas/mod-v1.json b/RustRaptor/schemas/mod-v1.json similarity index 93% rename from schemas/mod-v1.json rename to RustRaptor/schemas/mod-v1.json index 0b7e89f3..d7743eb0 100644 --- a/schemas/mod-v1.json +++ b/RustRaptor/schemas/mod-v1.json @@ -29,7 +29,7 @@ "type": "string" }, "dependencies": { - "description": "Required, explicitly enabled mod IDs mapped to semantic version requirements.", + "description": "Required mod IDs mapped to semantic version requirements.", "default": {}, "type": "object", "additionalProperties": { diff --git a/schemas/sound-v1.json b/RustRaptor/schemas/sound-v1.json similarity index 100% rename from schemas/sound-v1.json rename to RustRaptor/schemas/sound-v1.json diff --git a/schemas/weapon-projectile-v1.json b/RustRaptor/schemas/weapon-projectile-v1.json similarity index 100% rename from schemas/weapon-projectile-v1.json rename to RustRaptor/schemas/weapon-projectile-v1.json diff --git a/src/assets.rs b/RustRaptor/src/assets.rs similarity index 100% rename from src/assets.rs rename to RustRaptor/src/assets.rs diff --git a/src/audio.rs b/RustRaptor/src/audio.rs similarity index 93% rename from src/audio.rs rename to RustRaptor/src/audio.rs index b8fb4f8d..5c44ca9e 100644 --- a/src/audio.rs +++ b/RustRaptor/src/audio.rs @@ -162,6 +162,13 @@ struct SampleVoice { left: [i16; 256], right: [i16; 256], priority: i32, + volume: i32, + pan: usize, +} +pub struct AudioCapture { + pub rate: u32, + pub samples: Vec, + limit: usize, } struct Fade { level: i32, @@ -198,6 +205,8 @@ pub struct Mixer { digital_hold: [i32; 2], pub music_volume: u8, pub fx_volume: u8, + last_fx_volume: u8, + capture: Option, } impl Mixer { pub fn new(rate: u32, genmidi: &[u8]) -> Result { @@ -244,6 +253,8 @@ impl Mixer { digital_hold: [0; 2], music_volume: 85, fx_volume: 85, + last_fx_volume: 85, + capture: None, }) } pub fn play(&mut self, score: MusScore, looping: bool) { @@ -669,7 +680,8 @@ impl Mixer { } best }; - let volume = volume.clamp(0, 127) * self.fx_volume.min(127) as i32 / 127; + let event_volume = volume.clamp(0, 127); + let volume = event_volume * self.fx_volume.min(127) as i32 / 127; let left = volume_table(DSP_PAN[255 - pan] as i32 * volume / 127); let right = volume_table(DSP_PAN[pan] as i32 * volume / 127); self.digital[slot] = Some(SampleVoice { @@ -681,8 +693,43 @@ impl Mixer { left, right, priority, + volume: event_volume, + pan, }); } + pub fn start_capture(&mut self, seconds: u32) -> Result<()> { + ensure!( + (1..=120).contains(&seconds), + "capture duration must be 1–120 seconds" + ); + let limit = self.rate as usize * seconds as usize * 2; + self.capture = Some(AudioCapture { + rate: self.rate, + samples: Vec::with_capacity(limit), + limit, + }); + Ok(()) + } + pub fn take_capture(&mut self) -> Option { + self.capture.take() + } + fn refresh_fx_volume(&mut self) { + let master = self.fx_volume.min(127); + if master == self.last_fx_volume { + return; + } + self.last_fx_volume = master; + for voice in self.digital.iter_mut().flatten() { + let volume = voice.volume * master as i32 / 127; + voice.left = volume_table(DSP_PAN[255 - voice.pan] as i32 * volume / 127); + voice.right = volume_table(DSP_PAN[voice.pan] as i32 * volume / 127); + } + // Muting must silence the held sample immediately, including a callback + // beginning between two 11025 Hz digital service ticks. + if master == 0 { + self.digital_hold = [0; 2]; + } + } fn service_digital(&mut self) { let mut sample = [0i32; 2]; for slot in &mut self.digital[..self.digital_channels] { @@ -704,11 +751,18 @@ impl Mixer { self.digital_hold = sample.map(|v| v.clamp(-128, 127) * 128); } pub fn mix(&mut self, out: &mut [i16]) { + self.refresh_fx_volume(); // A partial stereo frame is explicitly silent, not leftover caller memory. out.fill(0); for frame in out.chunks_exact_mut(2) { let mut fm = [0i16; 2]; self.chip.generate_resampled(&mut fm); + // Maximum OPL attenuation still leaks a small signal. Keep the + // score and synthesizer advancing while a user-selected mute + // produces digital silence immediately. + if self.music_volume == 0 { + fm = [0; 2]; + } self.music_phase += 70; while self.music_phase >= self.rate { self.music_phase -= self.rate; @@ -727,6 +781,11 @@ impl Mixer { frame[0] = (fm[0] as i32 + self.digital_hold[0]).clamp(-32768, 32767) as i16; frame[1] = (fm[1] as i32 + self.digital_hold[1]).clamp(-32768, 32767) as i16; } + if let Some(capture) = &mut self.capture { + let remaining = capture.limit.saturating_sub(capture.samples.len()); + let count = remaining.min(out.len()) & !1; + capture.samples.extend_from_slice(&out[..count]); + } } } fn make_fade(level: i32, target: i32) -> Fade { diff --git a/src/audio/LICENSE-LGPL-2.1.txt b/RustRaptor/src/audio/LICENSE-LGPL-2.1.txt similarity index 100% rename from src/audio/LICENSE-LGPL-2.1.txt rename to RustRaptor/src/audio/LICENSE-LGPL-2.1.txt diff --git a/src/audio/opl3.rs b/RustRaptor/src/audio/opl3.rs similarity index 100% rename from src/audio/opl3.rs rename to RustRaptor/src/audio/opl3.rs diff --git a/src/cinematics.rs b/RustRaptor/src/cinematics.rs similarity index 100% rename from src/cinematics.rs rename to RustRaptor/src/cinematics.rs diff --git a/src/content.rs b/RustRaptor/src/content.rs similarity index 89% rename from src/content.rs rename to RustRaptor/src/content.rs index f75cb45a..4bf01b8c 100644 --- a/src/content.rs +++ b/RustRaptor/src/content.rs @@ -117,6 +117,9 @@ pub struct WeaponDef { pub struct ItemDef { pub id: ContentId, pub name: String, + /// Optional shop information, up to 4096 UTF-8 bytes. + #[serde(default)] + pub description: String, pub price: u32, #[serde(default = "one")] pub max_count: u32, @@ -139,6 +142,16 @@ pub struct ItemDef { #[serde(default)] pub legacy: Option, } +impl ItemDef { + /// Sales remove one ordinary unit, or one original shield refill/module. + pub fn resale_price(&self) -> u32 { + if matches!(self.id.0.as_str(), "base:energy" | "base:super_shield") { + self.price / 2 + } else { + self.price / self.initial_count.max(1) / 2 + } + } +} #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct EnemyDef { @@ -417,6 +430,11 @@ impl Registry { } for i in self.items.values() { label(&i.name, &i.script)?; + ensure!( + i.description.len() <= 4096, + "item {} description exceeds 4096 bytes", + i.id + ); ensure!( i.max_count > 0 && i.max_count <= 1_000_000 @@ -586,6 +604,27 @@ impl Registry { pack.items.push(ItemDef { id: id.clone(), name: o.name.into(), + description: match o.index { + 0 => "Paired forward guns. Fire automatically with the main fire control and strike air or ground targets.", + 1 => "Twin plasma guns. An always-equipped upgrade that fires alongside the forward guns at airborne targets.", + 2 => "Automatic micro missiles. Fire alongside the forward guns and seek airborne or ground enemies.", + 3 => "Selectable unguided missile launcher. Direct its forward shots at airborne or ground targets.", + 4 => "Selectable rapid-fire cannon. A concentrated stream of rounds for air and ground targets.", + 5 => "Selectable tracking turret. Instantly aims its gunfire at airborne targets; it cannot damage ground targets.", + 6 => "Selectable missile pods. Launch a spread of missiles against airborne targets.", + 7 => "Selectable air-to-air missiles. Seek airborne enemies; they cannot damage ground targets.", + 8 => "Selectable air-to-ground missiles. Seek ground targets; they cannot damage aircraft.", + 9 => "Selectable ground bomb. Damages ground targets and neighboring destructible terrain.", + 10 => "Selectable Energy Grab. Drains susceptible airborne enemies and suppresses their weapons.", + 11 => "One-use Mega Bomb. The bomb control damages visible enemies and terrain and clears hostile shots.", + 12 => "Selectable pulse cannon. A powerful forward energy weapon for air and ground targets.", + 13 => "Selectable forward laser. A high-end forward energy beam for airborne and ground targets.", + 14 => "Selectable Death Ray. Tracks airborne or ground targets with an energy beam.", + 15 => "Extra shielding with 100 charge per module. The equipped module absorbs hits first; a reserve replaces it when destroyed.", + 16 => "Restores 25 energy. With normal energy full, a smaller recharge goes to the equipped super shield.", + 17 => "Always-equipped damage detector. Shows a scanning target-health display during flight.", + _ => "Valuable salvage collected during a mission for credits.", + }.into(), price: o.cost, max_count: o.max_count.max(1), initial_count: o.initial_count, @@ -628,10 +667,18 @@ impl Registry { 6 => WeaponBehavior::Spread, _ => WeaponBehavior::Straight, }, - target: match w.target { - 1 => Target::Air, - 2 | 3 | 4 => Target::Ground, - _ => Target::All, + // Describe effective gameplay, including native firing/beam + // exceptions to the historical hit-type metadata. + target: if w.index == 5 { + Target::Air + } else if w.index == 13 { + Target::All + } else { + match w.target { + 1 | 5 => Target::Air, + 2 | 4 => Target::Ground, + _ => Target::All, + } }, art: sprite.map(|sprite| Art { asset: Some(w.asset), diff --git a/RustRaptor/src/desktop.rs b/RustRaptor/src/desktop.rs new file mode 100644 index 00000000..3240f4e3 --- /dev/null +++ b/RustRaptor/src/desktop.rs @@ -0,0 +1,5225 @@ +//! SDL host, interpolated scene presentation and local game menus. +use crate::{ + assets::{Catalog, Sprite}, + audio::{Mixer, MusScore}, + cinematics::{Cinematic, Kind as MovieKind}, + content::{Art, ContentId, Registry, art_sprite_variant}, + hd::HdAssets, + modding::{self, ActiveMods, Discovery}, + persistence::{Config, Paths, Pilot}, + render::{ + BitmapFont, Canvas, SpriteCache, legacy_light_table, nearest, prefers_nearest, + shade_air_shadow, shade_ground_shadow, upscale, + }, + simulation::{Clock, Input, Outcome, World}, +}; +use anyhow::{Context, Result, anyhow}; +use sdl2::{ + audio::{AudioCallback, AudioDevice, AudioSpecDesired}, + controller::{Axis, Button}, + event::Event, + keyboard::{Keycode, Scancode}, + pixels::PixelFormatEnum, + rect::Rect, + video::FullscreenType, +}; +use std::{ + collections::BTreeMap, + path::PathBuf, + time::{Duration, Instant}, +}; + +impl AudioCallback for Mixer { + type Channel = i16; + fn callback(&mut self, out: &mut [i16]) { + self.mix(out) + } +} +#[derive(Clone, Copy, PartialEq, Eq)] +enum Screen { + Main, + Hangar, + Extras, + PilotForm, + Missions, + Shop, + ShopDetails, + Inventory, + Mods, + Options, + Game, + Pause, + Results, + Help, + Credits, + Pilots, + LegacyPilots, + ConfirmDisable, + Diagnostics, + Bindings, + Cinematic, +} +struct PilotForm { + name: String, + callsign: String, + portrait: usize, + original_portrait: Option, + difficulty: u32, + creating: bool, + return_to: Screen, + field: Option, + replace_on_input: bool, +} +/// Display latches advance with simulation steps, including frames skipped by +/// the renderer. They never change gameplay state or consume the game's RNG. +struct HudState { + tick: u64, + shield: i32, + blink: bool, + damage_flashes: u8, + sweep: u8, +} +impl HudState { + fn new(world: &World) -> Self { + Self { + tick: world.tick, + shield: world.player.shield, + blink: true, + damage_flashes: 0, + sweep: 0, + } + } + fn observe(&mut self, world: &World, registry: &Registry) { + if self.tick == world.tick { + return; + } + if world.tick < self.tick { + *self = Self::new(world); + return; + } + if world.player.shield <= 10 { + if world.tick % 8 == 0 { + self.blink = !self.blink; + if self.blink { + self.damage_flashes = self.damage_flashes.saturating_sub(1); + } + } + if world.player.shield < self.shield && world.player.super_shield < 1 { + self.damage_flashes = 2; + } + } + if world + .player + .inventory + .get(&ContentId::from("base:detect")) + .is_some_and(|n| *n > 0) + && boss_shield_percent(world, registry) == 0 + { + self.sweep = (self.sweep + 1) % 50; + } + self.shield = world.player.shield; + self.tick = world.tick; + } +} +fn boss_shield_percent(world: &World, registry: &Registry) -> i32 { + let mut total = 0_i64; + let mut count = 0; + for enemy in &world.enemies { + let definition = ®istry.enemies[&enemy.definition]; + // ENEMY_GetBaseDamage checks the boss center reaching the screen. + if definition.boss && enemy.y + enemy.height / 2 >= 0 { + total += enemy.health.max(0) as i64 * 100 / definition.health.max(1) as i64; + count += 1; + } + } + if count == 0 { + 0 + } else { + (total / count).clamp(0, 100) as i32 + } +} +fn hud_shield_bar(canvas: &mut Canvas, x: i32, amount: i32) { + // RAP_DisplayShieldLevel uses a nine-color run with a 16.16 accumulator. + let increment = (9_u32 << 16) / 100; + for level in 0..100_u32 { + let color = if (level as i32) < amount.clamp(0, 100) { + 74 - ((level * increment) >> 16) as u16 + } else { + 0 + }; + canvas.rect(x, 199 - level as i32 * 2, 4, 1, color); + } +} +struct Host { + catalog: Catalog, + base: Registry, + active: ActiveMods, + paths: Paths, + mods_root: PathBuf, + discovery: Discovery, + config: Config, + hd: HdAssets, + read_only: bool, + pending: Vec, + pilot: Pilot, + slot: usize, + pilot_error: Option, + queued_cycle: bool, + queued_bomb: bool, + awaiting_binding: Option, + diagnostic_mod: Option, + diagnostic_return: Screen, + pending_disable: Option, + world: Option, + cinematic: Option, + movie_return: Screen, + screen: Screen, + previous: Screen, + selected: usize, + shop_selling: bool, + shop_detail_item: Option, + pilot_form: Option, + pilot_slots: Vec<(usize, String)>, + message: String, + font: BitmapFont, + cache: SpriteCache, + raw: BTreeMap, + mod_art: BTreeMap, + palette: [[u8; 3]; 256], + shadow_table: [u8; 256], + air_shadow_table: [u8; 256], + hud_state: HudState, + audio: Option>, + sound_bank: crate::sound::SoundBank, + desired_music: Option, + clock: Clock, +} +impl Host { + fn new( + catalog: Catalog, + base: Registry, + paths: Paths, + mods_root: PathBuf, + read_only: bool, + ) -> Result { + let config = Config::load(&paths)?; + let discovery = modding::discover(&mods_root); + let mut message = String::new(); + let mut active = + match ActiveMods::build(&base, &discovery, &config.enabled_mods, Some(&catalog)) { + Ok(a) => a, + Err(e) => { + message = format!("Mod load failed: {e:#}"); + ActiveMods::build(&base, &discovery, &[], Some(&catalog))? + } + }; + let mut pilot_error = None; + let mut pilot = if paths.save_path(0).exists() { + match Pilot::load(&paths.save_path(0), &active.registry, &active.pins) { + Ok(p) => p, + Err(e) => { + message = format!("Saved pilot unavailable: {e:#}"); + pilot_error = Some(message.clone()); + Pilot::default() + } + } + } else { + let legacy = paths.legacy.join("CHAR0000.FIL"); + if legacy.exists() { + match Pilot::import_legacy(&legacy, &active.registry) { + Ok(p) => p, + Err(e) => { + message = format!("Legacy import failed: {e:#}"); + Pilot::default() + } + } + } else { + Pilot::default() + } + }; + if !paths.save_path(0).exists() { + pilot.mods = active.pins.clone(); + } + restore_scripts(&mut active.scripts, &pilot); + let hd = HdAssets::load(&catalog.root.join("hd"), &catalog); + let palette = catalog.palette()?; + let vga_palette = palette.map(|color| color.map(|component| component >> 2)); + let shadow_table = legacy_light_table(&vga_palette, -9); + let air_shadow_table = legacy_light_table(&vga_palette, -6); + let font = BitmapFont::decode(&catalog.named("SYSTEM_FNT")?)?; + let cache = SpriteCache::new(Some(paths.cache.join("sprites"))); + let pending = config.enabled_mods.clone(); + Ok(Self { + catalog, + base, + active, + paths, + mods_root, + discovery, + config, + hd, + read_only, + pending, + pilot, + slot: 0, + pilot_error, + queued_cycle: false, + queued_bomb: false, + awaiting_binding: None, + diagnostic_mod: None, + diagnostic_return: Screen::Main, + pending_disable: None, + world: None, + cinematic: None, + movie_return: Screen::Main, + screen: Screen::Main, + previous: Screen::Main, + selected: 0, + shop_selling: false, + shop_detail_item: None, + pilot_form: None, + pilot_slots: vec![], + message, + font, + cache, + raw: BTreeMap::new(), + mod_art: BTreeMap::new(), + palette, + shadow_table, + air_shadow_table, + hud_state: HudState { + tick: 0, + shield: 100, + blink: true, + damage_flashes: 0, + sweep: 0, + }, + audio: None, + sound_bank: crate::sound::SoundBank::default(), + desired_music: None, + clock: Clock::default(), + }) + } + fn go(&mut self, screen: Screen) { + self.screen = screen; + self.selected = 0; + if screen == Screen::Shop { + self.shop_selling = false; + } + if screen == Screen::Pilots { + self.pilot_slots = self + .slots() + .into_iter() + .map(|slot| { + let path = self.paths.save_path(slot); + let identity = std::fs::metadata(&path) + .ok() + .filter(|meta| meta.len() <= 1_048_576) + .and_then(|_| std::fs::read(path).ok()) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|value| { + Some(format!( + "{} / {}", + value["callsign"].as_str()?, + value["name"].as_str()? + )) + }) + .unwrap_or_else(|| "unavailable - select for details".into()); + (slot, format!("{slot:04}: {identity}")) + }) + .collect(); + } + } + fn save_config(&self) -> Result<()> { + if self.read_only { + return Ok(()); + } + self.config.save(&self.paths) + } + fn save(&mut self) -> Result<()> { + self.require_pilot()?; + if self.read_only { + return Ok(()); + } + self.edit_pilot(|_, _| Ok(()))?; + Ok(()) + } + fn save_pilot_action(&mut self) -> Result<()> { + anyhow::ensure!(!self.read_only, "Saving is disabled in preview mode"); + self.require_pilot()?; + self.check_saved_slot()?; + if self.world.is_some() { + // Saving from Pause retains the last hangar inventory/progression + // and its script snapshot, never half-completed mission state. + let mut candidate = self.pilot.clone(); + candidate.validate(&self.active.registry, &self.active.pins)?; + candidate.mods = self.active.pins.clone(); + candidate.save(&self.paths.save_path(self.slot))?; + self.pilot = candidate; + } else { + self.save()?; + } + self.message = if self.world.is_some() { + format!( + "Saved {0} / {1:04}: hangar state only", + self.pilot.callsign, self.slot + ) + } else { + format!("Saved {0} / slot {1:04}", self.pilot.callsign, self.slot) + }; + self.play_ui_sound("FX_BONUS"); + Ok(()) + } + fn hangar(&mut self) { + self.go(Screen::Hangar); + self.music("HANGAR_MUS"); + } + fn music(&mut self, name: &str) { + self.desired_music = self.catalog.id(name).ok(); + let score = self.catalog.named(name).and_then(|b| MusScore::decode(&b)); + if let (Some(device), Ok(score)) = (&mut self.audio, score) { + device.lock().play_with_fade(score, true, true); + } + } + fn play_ui_sound(&mut self, name: &str) { + let Some(device) = &mut self.audio else { + return; + }; + let result = crate::sound::ui_event(name).and_then(|event| { + let sample = self.sound_bank.original(&self.catalog, event.asset)?; + device + .lock() + .sample(sample, event.pan, event.pitch, event.volume, event.priority); + Ok(()) + }); + if let Err(error) = result { + self.message = format!("Audio cue unavailable: {error:#}"); + } + } + fn start(&mut self, id: ContentId) -> Result<()> { + self.require_pilot()?; + self.pilot.mission = id.clone(); + self.pilot.difficulty = self.config.difficulty; + self.active.scripts.states = self.pilot.script_state.clone(); + self.active.scripts.rng = self.pilot.script_rng.clone(); + self.world = Some(World::new(&id, &self.pilot, &self.active.registry)?); + self.hud_state = HudState::new(self.world.as_ref().unwrap()); + if let Some(music) = self.active.registry.missions[&id].music { + self.desired_music = Some(music); + if let Ok(score) = self.catalog.read(music).and_then(|b| MusScore::decode(&b)) { + if let Some(audio) = &mut self.audio { + audio.lock().play_with_fade(score, true, true); + } + } + } + self.clock = Clock::default(); + self.go(Screen::Game); + Ok(()) + } + fn show_movie(&mut self, kind: MovieKind, return_to: Screen) -> Result<()> { + let movie = Cinematic::load(&self.catalog, kind)?; + self.cinematic = Some(movie); + self.movie_return = return_to; + match kind { + MovieKind::Intro => self.music("RINTRO_MUS"), + MovieKind::Death => self.music("RAP5_MUS"), + _ => {} + } + self.go(Screen::Cinematic); + Ok(()) + } + fn finish_movie(&mut self) { + self.cinematic = None; + self.go(self.movie_return); + if self.screen == Screen::Main { + self.music("MAINMENU_MUS"); + } + } + fn items(&self) -> Vec { + let mut ids: Vec<_> = self + .active + .registry + .items + .values() + .filter(|i| i.legacy.is_none_or(|n| n < 18)) + .map(|i| i.id.clone()) + .collect(); + ids.sort_by_key(|id| { + ( + self.active.registry.items[id].legacy.unwrap_or(u32::MAX), + id.clone(), + ) + }); + ids + } + fn inventory_ids(&self) -> Vec { + owned_items(&self.pilot, &self.active.registry) + } + fn shop_ids(&self) -> Vec { + if self.shop_selling { + self.inventory_ids() + .into_iter() + .filter(|id| sellable(id)) + .collect() + } else { + self.items() + } + } + fn missions(&self) -> Vec { + let mut ids: Vec<_> = self + .active + .registry + .campaigns + .values() + .flat_map(|c| c.missions.clone()) + .collect(); + let mut seen = std::collections::BTreeSet::new(); + ids.retain(|id| seen.insert(id.clone())); + for id in self.active.registry.missions.keys() { + if seen.insert(id.clone()) { + ids.push(id.clone()); + } + } + ids + } + fn rows(&self) -> Vec { + match self.screen { + Screen::Main => vec![ + "Return to hangar", + "New pilot", + "Load / import pilot", + "Options", + "Mods", + "Help / credits", + "Watch intro", + "Quit", + ] + .into_iter() + .map(str::to_string) + .collect(), + Screen::Hangar => [ + "Fly mission", + "Select mission", + "Supply room / shop", + "Inventory / equipment", + "Save pilot", + "Edit pilot ID", + "Exit hangar", + ] + .into_iter() + .map(str::to_string) + .collect(), + Screen::Extras => ["Flight controls", "Credits", "Return to main menu"] + .into_iter() + .map(str::to_string) + .collect(), + Screen::PilotForm => self + .pilot_form + .as_ref() + .map(|form| { + let mut rows = vec![ + format!( + "Name: {}{}", + form.name, + if form.field == Some(0) { "_" } else { "" } + ), + format!( + "Callsign: {}{}", + form.callsign, + if form.field == Some(1) { "_" } else { "" } + ), + format!("ID picture: {} / 4 (change)", form.portrait + 1), + ]; + if form.creating { + rows.push(format!( + "Difficulty: {} (change)", + ["Training", "Rookie", "Veteran", "Elite"][form.difficulty as usize] + )); + } + rows.push(if form.creating { + "Create pilot and enter hangar".into() + } else { + "Save pilot ID".into() + }); + rows.push("Cancel".into()); + rows + }) + .unwrap_or_default(), + Screen::Missions => self + .missions() + .iter() + .map(|id| { + format!( + "{}{}", + if self.pilot.completed.contains(id) { + "+ " + } else { + " " + }, + self.active.registry.missions[id].name + ) + }) + .collect(), + Screen::Shop => std::iter::once(if self.shop_selling { + "Mode: SELL (switch)".into() + } else { + "Mode: BUY (switch)".into() + }) + .chain(self.shop_ids().iter().map(|id| { + let i = &self.active.registry.items[id]; + format!( + "{} ${} x{}", + i.name, + if self.shop_selling { + i.resale_price() + } else { + i.price + }, + self.pilot.item_count(id) + ) + })) + .collect(), + Screen::Inventory => self + .inventory_ids() + .iter() + .map(|id| { + let i = &self.active.registry.items[id]; + let n = self.pilot.item_count(id); + format!( + "{} x{}{}", + i.name, + n, + if self.pilot.selected.as_ref() == Some(id) { + " [EQUIPPED]" + } else { + "" + } + ) + }) + .collect(), + Screen::ShopDetails => self + .shop_detail_item + .as_ref() + .and_then(|id| self.shop_info(id).ok()) + .map(|info| { + info.full_lines() + .into_iter() + .flat_map(|line| wrap_text(&self.font, &line, 274)) + .collect() + }) + .unwrap_or_default(), + Screen::Pilots => ["Create new pilot".into(), "Import legacy pilot".into()] + .into_iter() + .chain(self.pilot_slots.iter().map(|(_, label)| label.clone())) + .collect(), + Screen::LegacyPilots => self + .legacy_slots() + .iter() + .map(|path| { + format!( + "Import {}", + path.file_name().unwrap_or_default().to_string_lossy() + ) + }) + .collect(), + Screen::ConfirmDisable => [ + "Yes, disable these mods".into(), + "Cancel - keep enabled".into(), + ] + .into_iter() + .chain( + self.pending_disable + .iter() + .flat_map(|plan| plan.removed.iter()) + .map(|id| { + self.discovery + .mods + .get(id) + .map(|installed| { + format!( + "Disable: {} {}", + installed.manifest.name, installed.manifest.version + ) + }) + .unwrap_or_else(|| format!("Disable: {id}")) + }), + ) + .collect(), + Screen::Diagnostics => self.diagnostics(), + Screen::Bindings => ACTIONS + .iter() + .map(|action| format!("{}: {}", action, binding_label(&self.config, action))) + .chain(std::iter::once("Restore default bindings".into())) + .collect(), + Screen::Mods => { + let mut r = vec!["Apply selections".into(), "Rescan installed mods".into()]; + let mut ids = self.pending.clone(); + for id in self.discovery.mods.keys() { + if !ids.contains(id) { + ids.push(id.clone()); + } + } + for id in ids { + if let Some(m) = self.discovery.mods.get(&id) { + r.push(format!( + "[{}] {} {}", + if self.pending.contains(&id) { "X" } else { " " }, + m.manifest.name, + m.manifest.version + )); + } + } + r + } + Screen::Options => vec![ + if self.config.scale == 1 { + "Sprite scale: original".into() + } else { + format!("Sprite scale: {}x crisp", self.config.scale) + }, + format!("Scanlines: {}", self.config.scanlines), + format!("Fullscreen: {}", self.config.fullscreen), + format!("Borderless: {}", self.config.borderless), + format!("4:3 aspect: {}", self.config.aspect_correct), + format!("Music volume: {}", self.config.music_volume), + format!("Effects volume: {}", self.config.fx_volume), + format!("Controls: {}", self.config.control), + format!( + "Difficulty: {}", + ["Training", "Rookie", "Veteran", "Elite"][self.config.difficulty as usize] + ), + format!("Resolution: {}x{}", self.config.width, self.config.height), + format!( + "HD art: {} ({} frames)", + if self.config.hd_mode { "on" } else { "off" }, + self.hd.coverage() + ), + "Keyboard bindings".into(), + format!("Effects channels: {}", self.config.fx_channels), + "Save settings".into(), + "Return".into(), + ], + Screen::Pause => vec![ + "Resume", + "Options", + "Mods", + "Save pilot (hangar state)", + "Return to main menu", + ] + .into_iter() + .map(str::to_string) + .collect(), + Screen::Results => vec!["Return to hangar".into()], + _ => vec![], + } + } + fn mod_ids(&self) -> Vec { + let mut ids = self + .pending + .iter() + .filter(|id| self.discovery.mods.contains_key(*id)) + .cloned() + .collect::>(); + for id in self.discovery.mods.keys() { + if !ids.contains(id) { + ids.push(id.clone()); + } + } + ids + } + fn require_pilot(&self) -> Result<()> { + if let Some(error) = &self.pilot_error { + return Err(anyhow!( + "{error}. Restore matching mods, load a compatible pilot, or choose New pilot to create a separate pilot." + )); + } + self.pilot + .validate(&self.active.registry, &self.active.pins) + } + fn slots(&self) -> Vec { + let mut slots = std::fs::read_dir(&self.paths.data) + .into_iter() + .flatten() + .filter_map(|entry| { + let entry = entry.ok()?; + if !entry.file_type().ok()?.is_file() { + return None; + } + let name = entry.file_name(); + let name = name.to_str()?; + let number = name.strip_prefix("pilot-")?.strip_suffix(".json")?; + (number.len() == 4 && number.bytes().all(|b| b.is_ascii_digit())) + .then(|| number.parse::().ok()) + .flatten() + .filter(|slot| *slot < 1000) + }) + .collect::>(); + slots.sort_unstable(); + slots + } + fn legacy_slots(&self) -> Vec { + let mut slots = std::fs::read_dir(&self.paths.legacy) + .into_iter() + .flatten() + .filter_map(|entry| { + let entry = entry.ok()?; + if !entry.file_type().ok()?.is_file() { + return None; + } + let name = entry.file_name(); + let name = name.to_str()?; + let number = name.strip_prefix("CHAR")?.strip_suffix(".FIL")?; + (number.len() == 4 && number.bytes().all(|b| b.is_ascii_digit())) + .then(|| entry.path()) + }) + .collect::>(); + slots.sort(); + slots + } + fn unused_slot(&self) -> Result { + (0..1000) + .find(|n| std::fs::symlink_metadata(self.paths.save_path(*n)).is_err()) + .context("all pilot slots are occupied") + } + fn new_pilot(&mut self) -> Result<()> { + self.create_pilot("Pilot".into(), "RAPTOR".into(), 0) + } + fn create_pilot(&mut self, name: String, callsign: String, portrait: usize) -> Result<()> { + let slot = self.unused_slot()?; + let mut pilot = Pilot { + name, + callsign, + credits: 10000, + mods: self.active.pins.clone(), + difficulty: self.config.difficulty, + legacy_progress: Some(crate::persistence::LegacyProgress { + portrait: portrait_asset_id(portrait), + sector: 0, + waves: [0; 3], + difficulties: [ + self.config.difficulty, + self.config.difficulty, + self.config.difficulty, + 2, + ], + training: self.config.difficulty == 0, + training_completed: self.config.difficulty != 0, + }), + ..Pilot::default() + }; + pilot.inventory.insert("base:energy".into(), 75); + pilot.validate(&self.active.registry, &self.active.pins)?; + if !self.read_only { + pilot.save(&self.paths.save_path(slot))?; + } + self.slot = slot; + self.pilot = pilot; + self.pilot_error = None; + self.active.scripts.states.clear(); + self.active.scripts.rng.clear(); + self.message = format!("New pilot in slot {slot:04}"); + Ok(()) + } + fn begin_pilot_form(&mut self, creating: bool) -> Result<()> { + anyhow::ensure!( + self.world.is_none(), + "Return to the hangar before editing a pilot" + ); + if !creating { + self.require_pilot()?; + } + let return_to = self.screen; + self.pilot_form = Some(PilotForm { + name: if creating { + String::new() + } else { + self.pilot.name.clone() + }, + callsign: if creating { + String::new() + } else { + self.pilot.callsign.clone() + }, + portrait: self + .pilot + .legacy_progress + .as_ref() + .map_or(0, |p| portrait_index(p.portrait)), + original_portrait: if creating { + None + } else { + self.pilot + .legacy_progress + .as_ref() + .map(|progress| progress.portrait) + }, + difficulty: self.config.difficulty, + creating, + return_to, + field: Some(0), + replace_on_input: true, + }); + self.go(Screen::PilotForm); + self.message = "Enter name and callsign; ENTER advances".into(); + Ok(()) + } + fn type_pilot_text(&mut self, text: &str) { + let Some(form) = &mut self.pilot_form else { + return; + }; + let Some(field) = form.field else { + return; + }; + let value = if field == 0 { + &mut form.name + } else { + &mut form.callsign + }; + let text = text + .chars() + .filter(|ch| ch.is_ascii() && !ch.is_ascii_control()) + .collect::(); + if text.is_empty() { + return; + } + if form.replace_on_input { + value.clear(); + form.replace_on_input = false; + } + let available = (if field == 0 { 20_usize } else { 12 }).saturating_sub(value.len()); + value.extend(text.chars().take(available)); + } + fn pilot_field_key(&mut self, key: Keycode) -> bool { + let Some(form) = &mut self.pilot_form else { + return false; + }; + let Some(field) = form.field else { + return false; + }; + match key { + Keycode::Backspace => { + let value = if field == 0 { + &mut form.name + } else { + &mut form.callsign + }; + if form.replace_on_input { + value.clear(); + } else { + value.pop(); + } + form.replace_on_input = false; + } + Keycode::Delete => { + if field == 0 { + form.name.clear(); + } else { + form.callsign.clear(); + } + form.replace_on_input = false; + } + Keycode::Return | Keycode::Tab => { + form.field = (field == 0).then_some(1); + self.selected = if field == 0 { + 1 + } else if form.creating { + 4 + } else { + 3 + }; + form.replace_on_input = true; + } + Keycode::Escape | Keycode::Up | Keycode::Down => return false, + _ => {} + } + true + } + fn cancel_pilot_form(&mut self) { + let return_to = self + .pilot_form + .take() + .map_or(Screen::Main, |form| form.return_to); + self.go(return_to); + self.message = "Pilot ID unchanged".into(); + } + fn commit_pilot_form(&mut self) -> Result<()> { + let form = self.pilot_form.as_ref().context("pilot form is not open")?; + let name = form.name.trim().to_string(); + let callsign = form.callsign.trim().to_string(); + let creating = form.creating; + let portrait = form.portrait; + let stored_portrait = form + .original_portrait + .filter(|original| portrait_index(*original) == portrait) + .unwrap_or_else(|| portrait_asset_id(portrait)); + let difficulty = form.difficulty; + anyhow::ensure!(!name.is_empty(), "Enter the pilot's name"); + anyhow::ensure!(!callsign.is_empty(), "Enter a callsign"); + anyhow::ensure!( + name.len() <= 20 && callsign.len() <= 12, + "Name permits 20 characters; callsign permits 12" + ); + if creating { + let previous_difficulty = self.config.difficulty; + self.config.difficulty = difficulty; + if let Err(error) = self.create_pilot(name, callsign, portrait) { + self.config.difficulty = previous_difficulty; + return Err(error); + } + } else { + self.edit_pilot(|pilot, _| { + pilot.name = name; + pilot.callsign = callsign; + let progress = + pilot + .legacy_progress + .get_or_insert(crate::persistence::LegacyProgress { + portrait: 0, + sector: 0, + waves: [0; 3], + difficulties: [pilot.difficulty; 4], + training: pilot.difficulty == 0, + training_completed: false, + }); + progress.portrait = stored_portrait; + Ok(()) + })?; + self.message = "Pilot ID saved".into(); + } + self.pilot_form = None; + self.hangar(); + Ok(()) + } + fn import_pilot(&mut self, path: &std::path::Path) -> Result<()> { + let slot = self.unused_slot()?; + let mut pilot = Pilot::import_legacy(path, &self.active.registry)?; + pilot.mods = self.active.pins.clone(); + pilot.validate(&self.active.registry, &self.active.pins)?; + if !self.read_only { + pilot.save(&self.paths.save_path(slot))?; + } + restore_scripts(&mut self.active.scripts, &pilot); + self.pilot = pilot; + self.config.difficulty = self.pilot.difficulty; + self.slot = slot; + self.pilot_error = None; + self.message = format!("Imported {} into slot {slot:04}", self.pilot.callsign); + self.hangar(); + Ok(()) + } + fn edit_pilot(&mut self, edit: impl FnOnce(&mut Pilot, &Registry) -> Result<()>) -> Result<()> { + self.require_pilot()?; + self.check_saved_slot()?; + let mut candidate = self.pilot.clone(); + edit(&mut candidate, &self.active.registry)?; + candidate.script_state = self.active.scripts.states.clone(); + candidate.script_rng = self.active.scripts.rng.clone(); + candidate.validate(&self.active.registry, &self.active.pins)?; + candidate.mods = self.active.pins.clone(); + if !self.read_only { + candidate.save(&self.paths.save_path(self.slot))?; + } + self.pilot = candidate; + Ok(()) + } + fn check_saved_slot(&self) -> Result<()> { + let path = self.paths.save_path(self.slot); + if std::fs::symlink_metadata(&path).is_ok() { + Pilot::load(&path, &self.active.registry, &self.active.pins) + .with_context(|| format!("Existing pilot slot {:04} is unavailable; load matching mods or create a separate pilot", self.slot))?; + } + Ok(()) + } + fn diagnostics(&self) -> Vec { + let mut lines = vec![self.message.clone()]; + if let Some(mod_id) = &self.diagnostic_mod { + if let Some(installed) = self.discovery.mods.get(mod_id) { + let manifest = &installed.manifest; + lines.push(format!( + "{} {} / API {}", + manifest.name, manifest.version, manifest.api_version + )); + lines.push(manifest.description.clone()); + lines.extend(dependency_lines(manifest, &self.discovery, &self.pending)); + lines.push(format!( + "Conflicts: {}", + if manifest.conflicts.is_empty() { + "none".into() + } else { + manifest.conflicts.join(", ") + } + )); + } + } + if let Some(e) = &self.pilot_error { + lines.push(e.clone()); + } + lines.extend(self.discovery.errors.clone()); + lines.extend(self.hd.diagnostics.clone()); + lines.push(format!( + "HD art coverage: {} supplied frames; remaining artwork uses original sprites", + self.hd.coverage() + )); + lines.push(format!( + "Active order: {}", + self.active + .pins + .iter() + .map(|m| m.id.clone()) + .collect::>() + .join(", ") + )); + lines.extend(self.active.registry.overrides.clone()); + for m in self.discovery.mods.values() { + lines.push(format!( + "{} {}: {}", + m.manifest.id, m.manifest.version, m.manifest.description + )); + lines.push(format!( + "Dependencies: {:?}; conflicts: {:?}", + m.manifest.dependencies, m.manifest.conflicts + )); + } + lines + .into_iter() + .flat_map(|s| { + s.chars() + .collect::>() + .chunks(52) + .map(|c| c.iter().collect::()) + .collect::>() + }) + .collect() + } + fn open_diagnostics(&mut self) { + self.diagnostic_mod = if self.screen == Screen::Mods && self.selected >= 2 { + self.mod_ids().get(self.selected - 2).cloned() + } else { + None + }; + self.diagnostic_return = self.screen; + self.go(Screen::Diagnostics); + self.play_ui_sound("FX_SWEP"); + } + fn apply_mods(&mut self) -> Result<()> { + let mut candidate = ActiveMods::build( + &self.base, + &self.discovery, + &self.pending, + Some(&self.catalog), + )?; + let mut config = self.config.clone(); + config.enabled_mods = self.pending.clone(); + if !self.read_only { + config.save(&self.paths)?; + } + let saved = self.paths.save_path(self.slot); + match pilot_for_mods(&saved, &self.pilot, &candidate.registry, &candidate.pins) { + Ok(pilot) => { + restore_scripts(&mut candidate.scripts, &pilot); + self.pilot = pilot; + self.pilot_error = None; + } + Err(e) => self.pilot_error = Some(format!("Pilot slot {:04}: {e:#}", self.slot)), + } + self.config = config; + self.active = candidate; + self.sound_bank.clear_mods(); + self.mod_art.clear(); + if self.pilot_error.is_some() { + self.message = "Mods applied; restore saved mods or choose Load / New pilot".into(); + } else { + self.message = "Mods applied".into(); + } + Ok(()) + } + fn activate(&mut self) -> Result { + match self.screen { + Screen::Main => match self.selected { + 0 => { + self.require_pilot()?; + self.hangar(); + } + 1 => self.begin_pilot_form(true)?, + 2 => self.go(Screen::Pilots), + 3 => { + self.previous = Screen::Main; + self.go(Screen::Options); + } + 4 => self.go(Screen::Mods), + 5 => self.go(Screen::Extras), + 6 => self.show_movie(MovieKind::Intro, Screen::Main)?, + 7 => return Ok(false), + _ => {} + }, + Screen::Hangar => match self.selected { + 0 => self.start(self.pilot.mission.clone())?, + 1 => self.go(Screen::Missions), + 2 => { + self.go(Screen::Shop); + self.play_ui_sound("FX_DOOR"); + } + 3 => self.go(Screen::Inventory), + 4 => self.save_pilot_action()?, + 5 => self.begin_pilot_form(false)?, + 6 => self.return_main()?, + _ => {} + }, + Screen::Extras => match self.selected { + 0 => self.go(Screen::Help), + 1 => self.go(Screen::Credits), + 2 => self.return_main()?, + _ => {} + }, + Screen::PilotForm => match self.selected { + 0 | 1 => { + if let Some(form) = &mut self.pilot_form { + form.field = Some(self.selected); + form.replace_on_input = true; + } + } + 2 => { + if let Some(form) = &mut self.pilot_form { + form.portrait = (form.portrait + 1) % 4; + form.field = None; + } + } + _ => { + let creating = self.pilot_form.as_ref().is_some_and(|form| form.creating); + if creating && self.selected == 3 { + if let Some(form) = &mut self.pilot_form { + form.difficulty = (form.difficulty + 1) % 4; + form.field = None; + } + } else if self.selected == if creating { 4 } else { 3 } { + self.commit_pilot_form()?; + } else if self.selected == if creating { 5 } else { 4 } { + self.cancel_pilot_form(); + } + } + }, + Screen::Missions => { + if let Some(id) = self.missions().get(self.selected) { + self.start(id.clone())?; + } + } + Screen::Pilots => { + if self.selected == 0 { + self.begin_pilot_form(true)?; + } else if self.selected == 1 { + self.go(Screen::LegacyPilots); + if self.legacy_slots().is_empty() { + self.message = + format!("No CHARxxxx.FIL pilots in {}", self.paths.legacy.display()); + } + } else if let Some(slot) = self + .pilot_slots + .get(self.selected - 2) + .map(|(slot, _)| *slot) + { + let p = Pilot::load( + &self.paths.save_path(slot), + &self.active.registry, + &self.active.pins, + )?; + restore_scripts(&mut self.active.scripts, &p); + self.pilot = p; + self.config.difficulty = self.pilot.difficulty; + self.slot = slot; + self.pilot_error = None; + self.message = format!("Loaded pilot slot {slot:04}"); + self.hangar(); + } + } + Screen::LegacyPilots => { + if let Some(path) = self.legacy_slots().get(self.selected).cloned() { + self.import_pilot(&path)?; + } + } + Screen::Shop => { + if self.selected == 0 { + self.toggle_shop_mode(); + } else if let Some(id) = self.shop_ids().get(self.selected - 1).cloned() { + let selling = self.shop_selling; + self.edit_pilot(|pilot, registry| { + if selling { + pilot.sell(&id, registry) + } else { + pilot.buy(&id, registry) + } + })?; + self.selected = self.selected.min(self.rows().len().saturating_sub(1)); + self.message = if selling { + "Sale complete" + } else { + "Purchase complete" + } + .into(); + self.play_ui_sound("FX_BONUS"); + } + } + Screen::Inventory => { + if let Some(id) = self.inventory_ids().get(self.selected).cloned() { + if self + .active + .registry + .items + .get(&id) + .is_some_and(|i| i.special) + { + self.edit_pilot(|pilot, _| { + pilot.selected = Some(id); + Ok(()) + })?; + self.message = "Weapon equipped".into(); + } else { + self.message = "Primary equipment is always active".into(); + } + } + } + Screen::Mods => { + if self.selected == 0 { + if self.world.is_some() { + self.config.enabled_mods = self.pending.clone(); + self.save_config()?; + self.message = "Changes pending until the main menu".into(); + } else { + self.apply_mods()?; + } + } else if self.selected == 1 { + self.discovery = modding::discover(&self.mods_root); + self.message = self.discovery.errors.join("; "); + } else if let Some(id) = self.mod_ids().get(self.selected - 2).cloned() { + if self.pending.contains(&id) { + let plan = + crate::mod_selection::plan_disable(&self.discovery, &self.pending, &id); + if plan.removed.len() == 1 { + self.pending = plan.enabled; + self.message = format!("Deselected {id}. Choose Apply selections"); + self.selected = self + .mod_ids() + .iter() + .position(|selected| selected == &id) + .unwrap_or(0) + + 2; + } else { + self.pending_disable = Some(plan); + self.go(Screen::ConfirmDisable); + self.selected = 1; // Default to keeping the enabled selection. + } + } else { + let enabled = + crate::mod_selection::plan_enable(&self.discovery, &self.pending, &id)?; + let added = enabled + .iter() + .filter(|id| !self.pending.contains(id)) + .cloned() + .collect::>(); + self.pending = enabled; + self.selected = self + .mod_ids() + .iter() + .position(|selected| selected == &id) + .unwrap_or(0) + + 2; + self.message = + format!("Selected {}. Choose Apply selections", added.join(", ")); + } + } + } + Screen::ConfirmDisable => { + if self.selected == 0 { + if let Some(plan) = self.pending_disable.take() { + self.pending = plan.enabled; + self.message = format!( + "Deselected {}. Choose Apply selections", + plan.removed.join(", ") + ); + } + self.go(Screen::Mods); + } else if self.selected == 1 { + self.pending_disable = None; + self.message = "Mod selection unchanged".into(); + self.go(Screen::Mods); + } + } + Screen::Options => { + if self.selected == 11 { + self.go(Screen::Bindings); + } else if self.selected == 13 { + anyhow::ensure!(!self.read_only, "Saving is disabled in preview mode"); + self.save_config()?; + self.message = "Settings saved".into(); + } else if self.selected == 14 { + self.back()?; + } else { + adjust_option(&mut self.config, self.selected, 1); + } + } + Screen::Bindings => { + if let Some(action) = ACTIONS.get(self.selected) { + self.awaiting_binding = Some(action.to_string()); + self.message = format!("Press a key for {action}; ESC cancels"); + } else { + self.config.bindings = Config::default().bindings; + self.save_config()?; + self.message = "Default bindings restored".into(); + } + } + Screen::Pause => match self.selected { + 0 => self.go(Screen::Game), + 1 => { + self.previous = Screen::Pause; + self.go(Screen::Options) + } + 2 => self.go(Screen::Mods), + 3 => self.save_pilot_action()?, + 4 => self.return_main()?, + _ => {} + }, + Screen::Results => { + self.return_main()?; + self.hangar(); + } + _ => {} + } + Ok(true) + } + fn choose(&mut self) -> Result { + let result = self.activate(); + self.play_ui_sound(if result.is_ok() { + "FX_SWEP" + } else { + "FX_WARNING" + }); + result + } + fn move_selection(&mut self, delta: i32) { + if self.screen == Screen::PilotForm { + if let Some(form) = &mut self.pilot_form { + form.field = None; + } + } + let next = (self.selected as i64 + i64::from(delta)) + .clamp(0, self.rows().len().saturating_sub(1) as i64) as usize; + if next != self.selected { + self.selected = next; + self.play_ui_sound("FX_SWEP"); + } + } + fn adjust(&mut self, direction: i32) { + if self.screen == Screen::PilotForm && self.selected == 3 { + if let Some(form) = &mut self.pilot_form { + if form.creating { + form.difficulty = + (form.difficulty as i32 + direction.signum()).rem_euclid(4) as u32; + self.play_ui_sound("FX_SWEP"); + } + } + } else if self.screen == Screen::Options { + if adjust_option(&mut self.config, self.selected, direction) { + self.play_ui_sound("FX_SWEP"); + } + } else if self.screen == Screen::Mods && self.selected >= 2 { + if let Some(id) = self.mod_ids().get(self.selected - 2) { + if let Some(n) = self.pending.iter().position(|m| m == id) { + let next = if direction < 0 { + n.saturating_sub(1) + } else { + (n + 1).min(self.pending.len() - 1) + }; + if next != n { + self.pending.swap(n, next); + // Keep the selected mod under the cursor as the order changes. + self.selected = next + 2; + self.play_ui_sound("FX_SWEP"); + } + } + } + } + } + fn sell_inventory(&mut self) -> Result<()> { + if let Some(id) = self.inventory_ids().get(self.selected).cloned() { + self.edit_pilot(|pilot, registry| pilot.sell(&id, registry))?; + self.selected = self.selected.min(self.rows().len().saturating_sub(1)); + self.message = "Sale complete".into(); + } + Ok(()) + } + fn open_shop_details(&mut self) { + if let Some(id) = self + .selected + .checked_sub(1) + .and_then(|index| self.shop_ids().get(index).cloned()) + { + self.shop_detail_item = Some(id); + self.go(Screen::ShopDetails); + self.play_ui_sound("FX_SWEP"); + } + } + fn toggle_shop_mode(&mut self) { + let item = self + .selected + .checked_sub(1) + .and_then(|index| self.shop_ids().get(index).cloned()); + self.shop_selling = !self.shop_selling; + self.selected = item + .and_then(|item| { + self.shop_ids() + .iter() + .position(|candidate| candidate == &item) + }) + .map_or(0, |index| index + 1); + } + fn return_main(&mut self) -> Result<()> { + self.world = None; + self.clock = Clock::default(); + if self.pending + != self + .active + .pins + .iter() + .map(|m| m.id.clone()) + .collect::>() + { + if let Err(e) = self.apply_mods() { + self.message = format!("Mod changes pending: {e:#}"); + } + } + self.go(Screen::Main); + self.music("MAINMENU_MUS"); + Ok(()) + } + fn back(&mut self) -> Result<()> { + match self.screen { + Screen::ShopDetails => { + let selling = self.shop_selling; + self.go(Screen::Shop); + self.shop_selling = selling; + self.selected = self + .shop_detail_item + .as_ref() + .and_then(|id| self.shop_ids().iter().position(|candidate| candidate == id)) + .map_or(0, |index| index + 1); + } + Screen::ConfirmDisable => { + self.pending_disable = None; + self.message = "Mod selection unchanged".into(); + self.go(Screen::Mods); + } + Screen::Cinematic => self.finish_movie(), + Screen::PilotForm => self.cancel_pilot_form(), + Screen::Game => self.go(Screen::Pause), + Screen::Pause => self.go(Screen::Game), + Screen::Options => { + self.save_config()?; + self.go(self.previous) + } + Screen::Mods if self.world.is_some() => self.go(Screen::Pause), + Screen::Diagnostics => self.go(self.diagnostic_return), + Screen::Bindings => { + self.awaiting_binding = None; + self.save_config()?; + self.go(Screen::Options); + } + Screen::Results => { + self.return_main()?; + self.hangar(); + } + Screen::LegacyPilots => self.go(Screen::Pilots), + Screen::Missions | Screen::Shop | Screen::Inventory => self.hangar(), + Screen::Help | Screen::Credits => self.go(Screen::Extras), + _ => { + self.go(Screen::Main); + self.music("MAINMENU_MUS"); + } + } + self.play_ui_sound("FX_SWEP"); + Ok(()) + } + fn update(&mut self, input: Input, nanos: u64) -> Result<()> { + if self.screen == Screen::Cinematic { + let movie = self + .cinematic + .as_mut() + .context("missing active cinematic")?; + movie.advance(nanos)?; + let sounds = std::mem::take(&mut movie.sounds); + let finished = movie.finished(); + if let Some(audio) = &mut self.audio { + for event in sounds { + match self.sound_bank.original(&self.catalog, event.asset) { + Ok(sample) => audio.lock().sample( + sample, + event.pan, + event.pitch, + event.volume, + event.priority, + ), + Err(error) => { + self.message = format!("Cinematic sound unavailable: {error:#}") + } + } + } + } + if finished { + self.finish_movie(); + } + return Ok(()); + } + if self.screen != Screen::Game { + return Ok(()); + } + self.queued_cycle |= input.cycle; + self.queued_bomb |= input.bomb; + let steps = self.clock.advance(nanos); + let mut input = input; + input.cycle = self.queued_cycle; + input.bomb = self.queued_bomb; + for _ in 0..steps { + let w = self.world.as_mut().context("missing active mission")?; + w.step(input, &self.active.registry, &mut self.active.scripts)?; + // SDL cycle/bomb inputs are non-repeating button-down pulses. Release + // their simulation latches after delivery so a second genuine tap + // on the next step is accepted. World::step still debounces held + // inputs supplied directly by demos and headless controllers. + if input.cycle { + w.cycle_held = false; + } + if input.bomb { + w.bomb_held = false; + } + self.hud_state.observe(w, &self.active.registry); + input.cycle = false; + input.bomb = false; + self.queued_cycle = false; + self.queued_bomb = false; + if let Some(audio) = &mut self.audio { + for event in &w.sound_events { + match self.sound_bank.original(&self.catalog, event.asset) { + Ok(sample) => audio.lock().sample( + sample, + event.pan, + event.pitch, + event.volume, + event.priority, + ), + Err(error) => { + self.message = format!("Mission sound unavailable: {error:#}") + } + } + } + if w.player.shield <= 10 && self.hud_state.blink && w.end_timer.is_none() { + let event = crate::sound::ui_event("FX_WARNING")?; + match self.sound_bank.original(&self.catalog, event.asset) { + Ok(sample) => audio.lock().sample( + sample, + event.pan, + event.pitch, + event.volume, + event.priority, + ), + Err(error) => { + self.message = format!("Shield warning unavailable: {error:#}") + } + } + } + for id in &w.mod_sounds { + match self.sound_bank.mod_sample(&self.active.registry, id) { + Ok(sample) => { + let event = &self.active.registry.sounds[id]; + audio.lock().sample( + sample, + event.pan, + event.pitch, + event.volume, + event.priority as i32, + ); + } + Err(error) => self.message = format!("Mod sound unavailable: {error:#}"), + } + } + } + if w.outcome != Outcome::Playing { + let outcome = w.outcome; + let mission = w.mission.clone(); + let mut pilot = w.pilot(&self.pilot); + if outcome == Outcome::Complete { + pilot.complete_mission(&self.active.registry)?; + self.edit_pilot(move |candidate, _| { + *candidate = pilot; + Ok(()) + })?; + self.config.difficulty = self.pilot.difficulty; + // The pilot is committed. A settings failure must not + // retry progression against an already advanced pilot. + self.message = match self.save_config() { + Ok(()) => "Mission complete - pilot saved".into(), + Err(error) => format!("Pilot saved; settings save failed: {error:#}"), + }; + } else { + self.message = "Ship lost - previous hangar save retained".into(); + } + let kind = if outcome == Outcome::Failed { + MovieKind::Death + } else if mission.0.ends_with("_wave_9") { + let sector = mission + .0 + .strip_prefix("base:sector_") + .and_then(|s| s.split('_').next()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(1); + MovieKind::CampaignEnd(sector.saturating_sub(1)) + } else { + MovieKind::Landing + }; + if mission.0.starts_with("base:") { + if let Err(e) = self.show_movie(kind, Screen::Results) { + self.message = format!("{}; cinematic unavailable: {e:#}", self.message); + self.go(Screen::Results); + } + } else { + self.go(Screen::Results); + } + break; + } + } + Ok(()) + } + fn raw(&mut self, id: u32) -> Result { + if let Some(s) = self.raw.get(&id) { + return Ok(s.clone()); + } + let s = self.catalog.sprite(id)?; + self.raw.insert(id, s.clone()); + Ok(s) + } + fn art( + &mut self, + canvas: &mut Canvas, + art: &Art, + owner: &str, + frame: u32, + x: f32, + y: f32, + ) -> Result<()> { + let key = if art.png.is_some() || art.hd_png.is_some() { + Some(format!( + "{owner}:{}:{frame}:{}:{}", + serde_json::to_string(art)?, + canvas.scale, + self.config.hd_mode + )) + } else { + None + }; + if let Some(sprite) = key.as_ref().and_then(|k| self.mod_art.get(k)) { + canvas.blit( + sprite, + (x * canvas.scale as f32).round() as i32, + (y * canvas.scale as f32).round() as i32, + ); + return Ok(()); + } + if let Some(id) = art.asset { + if self.config.hd_mode && art.hd_png.is_none() { + if let Some(sprite) = + self.hd + .sprite(id + frame % art.frames, canvas.scale, &self.palette)? + { + if sprite.width == art.logical_width as usize * canvas.scale + && sprite.height == art.logical_height as usize * canvas.scale + { + canvas.blit( + &sprite, + (x * canvas.scale as f32).round() as i32, + (y * canvas.scale as f32).round() as i32, + ); + return Ok(()); + } + } + } + } + let sprite = if let Some(id) = art + .asset + .filter(|_| !(self.config.hd_mode && art.hd_png.is_some())) + { + self.raw(id + frame % art.frames)? + } else { + art_sprite_variant( + &self.catalog, + &self.active.registry, + owner, + art, + frame, + self.config.hd_mode, + )? + }; + let scaled = if sprite.width == art.logical_width as usize + && sprite.height == art.logical_height as usize + { + let pixelated = art.nearest + || art + .asset + .is_some_and(|id| prefers_nearest(id + frame % art.frames)); + self.cache.get(&sprite, canvas.scale, pixelated)?.clone() + } else { + let mut out = Sprite::blank( + art.logical_width as usize * canvas.scale, + art.logical_height as usize * canvas.scale, + ); + for yy in 0..out.height { + for xx in 0..out.width { + out.pixels[yy * out.width + xx] = + sprite.pixels[(yy * sprite.height / out.height) * sprite.width + + xx * sprite.width / out.width]; + } + } + out + }; + canvas.blit( + &scaled, + (x * canvas.scale as f32).round() as i32, + (y * canvas.scale as f32).round() as i32, + ); + if let Some(key) = key { + if self + .mod_art + .values() + .map(|s| s.pixels.len() * 2) + .sum::() + + scaled.pixels.len() * 2 + > 128 * 1024 * 1024 + { + self.mod_art.clear(); + } + self.mod_art.insert(key, scaled); + } + Ok(()) + } + fn shadow( + &mut self, + canvas: &mut Canvas, + art: &Art, + owner: &str, + frame: u32, + x: f32, + y: f32, + ground: bool, + ) -> Result<()> { + // Original masks retain their native canvas even when HD art is enabled. + // Mod masks use the supplied SD artwork and explicit logical dimensions. + let source = if let Some(asset) = art.asset { + self.raw(asset + frame % art.frames)? + } else { + art_sprite_variant( + &self.catalog, + &self.active.registry, + owner, + art, + frame, + false, + )? + }; + let mask = if (source.width, source.height) + == (art.logical_width as usize, art.logical_height as usize) + { + source + } else { + let mut logical = + Sprite::blank(art.logical_width as usize, art.logical_height as usize); + for row in 0..logical.height { + for column in 0..logical.width { + logical.pixels[row * logical.width + column] = + source.pixels[(row * source.height / logical.height) * source.width + + column * source.width / logical.width]; + } + } + logical + }; + if ground { + shade_ground_shadow(canvas, &mask, x, y, &self.shadow_table); + } else { + shade_air_shadow(canvas, &mask, x, y, &self.air_shadow_table); + } + Ok(()) + } + fn projectiles( + &mut self, + canvas: &mut Canvas, + world: &World, + hostile: bool, + alpha: f32, + ) -> Result<()> { + for p in world.projectiles.iter().filter(|p| p.hostile == hostile) { + let def = self.active.registry.weapons[&p.weapon].clone(); + let x = p.px as f32 + (p.x - p.px) as f32 * alpha; + let y = p.py as f32 + (p.y - p.py) as f32 * alpha; + if p.legacy.is_some() || p.legacy_enemy.is_some() { + if let Some(asset) = World::projectile_asset(p, &self.active.registry) { + let frame = if let Some(shot) = &p.legacy_enemy { + shot.art_frame % shot.frames.max(1) + } else { + p.legacy.as_ref().map_or(0, |shot| shot.art_frame) + }; + let original = self.raw(asset + frame)?; + let hd = if self.config.hd_mode { + self.hd.sprite(asset + frame, canvas.scale, &self.palette)? + } else { + None + }; + let sprite = match hd { + Some(sprite) => sprite, + None => self + .cache + .get(&original, canvas.scale, prefers_nearest(asset + frame))? + .clone(), + }; + canvas.blit( + &sprite, + (x * canvas.scale as f32).round() as i32, + (y * canvas.scale as f32).round() as i32, + ); + } + } else if matches!(def.behavior, crate::content::WeaponBehavior::Beam) { + canvas.rect(x as i32, 0, 2, world.player.y, 255); + } else if let Some(art) = def.art { + let owner = self.active.registry.owners[&p.weapon].clone(); + self.art( + canvas, + &art, + &owner, + p.age / 2, + x - art.logical_width as f32 / 2.0, + y - art.logical_height as f32 / 2.0, + )?; + } else { + canvas.rect(x as i32, y as i32, 2, 4, if p.hostile { 34 } else { 255 }); + } + } + Ok(()) + } + fn effects( + &mut self, + canvas: &mut Canvas, + world: &World, + ground: bool, + alpha: f32, + ) -> Result<()> { + for effect in world.effects.iter().filter(|e| e.ground == ground) { + let (asset, frames) = effect.kind.artwork(); + let frame = effect.age.saturating_sub(1).min(frames - 1); + let original = self.raw(asset + frame)?; + let hd = if self.config.hd_mode { + self.hd.sprite(asset + frame, canvas.scale, &self.palette)? + } else { + None + }; + let sprite = match hd { + Some(sprite) => sprite, + None => self + .cache + .get(&original, canvas.scale, prefers_nearest(asset + frame))? + .clone(), + }; + let x = effect.px as f32 + (effect.x - effect.px) as f32 * alpha + - original.width as f32 / 2.0; + let y = effect.py as f32 + (effect.y - effect.py) as f32 * alpha + - original.height as f32 / 2.0; + canvas.blit( + &sprite, + (x * canvas.scale as f32).round() as i32, + (y * canvas.scale as f32).round() as i32, + ); + } + Ok(()) + } + fn shop_info(&self, id: &ContentId) -> Result { + shop_info( + &self.pilot, + &self.active.registry, + id, + self.require_pilot().err().map(|error| format!("{error:#}")), + ) + } + fn shop_panel(&mut self, canvas: &mut Canvas) -> Result<()> { + canvas.rect(164, 43, 138, 126, closest(&self.palette, [15, 24, 42])); + canvas.rect(164, 43, 1, 126, 100); + let Some(id) = self + .selected + .checked_sub(1) + .and_then(|index| self.shop_ids().get(index).cloned()) + else { + for (index, line) in wrap_text(&self.font, "Choose BUY or SELL, then select equipment to view its image, price and flight behavior. Click this panel or press D/X for full details.", 126).iter().enumerate() { + self.font.text(canvas, 172, 51 + index as i32 * 10, line, 100); + } + return Ok(()); + }; + let info = self.shop_info(&id)?; + for (index, line) in wrap_text(&self.font, &info.name, 126) + .iter() + .take(2) + .enumerate() + { + self.font.text(canvas, 172, 45 + index as i32 * 8, line, 50); + } + if let Some(art) = self.active.registry.items[&id].art.clone() { + let owner = self + .active + .registry + .owners + .get(&id) + .cloned() + .unwrap_or_else(|| "base".into()); + // Render with the original logical canvas first so the normal SD/HD + // asset path and transparency rules remain intact in this preview. + let mut preview = Canvas { + image: Sprite::blank( + art.logical_width as usize * canvas.scale, + art.logical_height as usize * canvas.scale, + ), + scale: canvas.scale, + }; + self.art(&mut preview, &art, &owner, 0, 0.0, 0.0)?; + let factor = (40.0 / art.logical_width as f32).min(32.0 / art.logical_height as f32); + let width = (art.logical_width as f32 * factor).round().max(1.0) as usize; + let height = (art.logical_height as f32 * factor).round().max(1.0) as usize; + let mut thumbnail = Sprite::blank(width * canvas.scale, height * canvas.scale); + for y in 0..thumbnail.height { + for x in 0..thumbnail.width { + thumbnail.pixels[y * thumbnail.width + x] = + preview.image.pixels[(y * preview.image.height / thumbnail.height) + * preview.image.width + + x * preview.image.width / thumbnail.width]; + } + } + canvas.blit( + &thumbnail, + (172 + (40 - width as i32) / 2) * canvas.scale as i32, + (64 + (32 - height as i32) / 2) * canvas.scale as i32, + ); + } else { + self.font.text(canvas, 172, 77, "NO IMAGE", 100); + } + for (y, text) in [ + (64, info.kind.to_string()), + (73, format!("Buy ${}", info.price)), + (82, format!("Sell ${}", info.resale)), + (91, format!("Owned {}", info.owned)), + ] { + self.font.text( + canvas, + 216, + y, + &fit_text(&self.font, &text, 82), + if y == 64 { 50 } else { 100 }, + ); + } + for (index, line) in info.stats.iter().take(4).enumerate() { + self.font.text( + canvas, + 172, + 104 + index as i32 * 8, + &fit_text(&self.font, line, 126), + 100, + ); + } + for (index, line) in wrap_text(&self.font, &info.description, 126) + .iter() + .take(2) + .enumerate() + { + self.font + .text(canvas, 172, 136 + index as i32 * 8, line, 100); + } + let status = if self.shop_selling { + format!("SELL: {}", info.sell) + } else { + format!("BUY: {}", info.buy) + }; + for (index, line) in wrap_text(&self.font, &status, 126) + .iter() + .take(2) + .enumerate() + { + self.font.text( + canvas, + 172, + 154 + index as i32 * 8, + line, + if status.ends_with("Ready") { 50 } else { 34 }, + ); + } + Ok(()) + } + fn menu_portrait( + &mut self, + canvas: &mut Canvas, + portrait: usize, + x: i32, + y: i32, + ) -> Result<()> { + let name = ["WMALEID_PIC", "BMALEID_PIC", "WFMALEID_PIC", "BFMALEID_PIC"][portrait.min(3)]; + if let Ok(id) = self.catalog.id(name) { + let image = self.raw(id)?; + canvas.blit( + &nearest(&image, canvas.scale), + x * canvas.scale as i32, + y * canvas.scale as i32, + ); + } + Ok(()) + } + fn classic_menu(&mut self, canvas: &mut Canvas) -> Result<()> { + let panel = closest(&self.palette, [10, 15, 32]); + let edge = closest(&self.palette, [70, 95, 115]); + canvas.rect(12, 42, 144, 126, panel); + canvas.rect(12, 42, 144, 1, edge); + canvas.rect(12, 167, 144, 1, edge); + self.font.text(canvas, 22, 49, "PILOT ID", 50); + let portrait = self + .pilot + .legacy_progress + .as_ref() + .map_or(0, |p| portrait_index(p.portrait)); + self.menu_portrait(canvas, portrait, 22, 62)?; + self.font.text( + canvas, + 70, + 63, + &fit_text(&self.font, &self.pilot.callsign, 80), + 50, + ); + for (index, line) in wrap_text(&self.font, &self.pilot.name, 80) + .iter() + .take(2) + .enumerate() + { + self.font.text(canvas, 70, 74 + index as i32 * 9, line, 100); + } + self.font.text( + canvas, + 22, + 110, + &format!("CREDITS: ${}", self.pilot.credits), + 100, + ); + self.font.text(canvas, 22, 123, "NEXT MISSION", 50); + let mission = self + .active + .registry + .missions + .get(&self.pilot.mission) + .map(|m| m.name.clone()) + .unwrap_or_else(|| self.pilot.mission.0.clone()); + for (index, line) in wrap_text(&self.font, &mission, 124) + .iter() + .take(2) + .enumerate() + { + self.font + .text(canvas, 22, 134 + index as i32 * 9, line, 100); + } + self.font.text( + canvas, + 22, + 157, + &format!( + "SLOT {:04} / {}", + self.slot, + ["TRAIN", "ROOKIE", "VETERAN", "ELITE"][self.config.difficulty as usize] + ), + 100, + ); + for (index, label) in self.rows().iter().enumerate() { + let y = 42 + index as i32 * 16; + canvas.rect(164, y, 136, 15, panel); + canvas.rect( + 164, + y, + 136, + 1, + if index == self.selected { 50 } else { edge }, + ); + canvas.rect(164, y + 14, 136, 1, edge); + let asset = if self.screen == Screen::Main { + match index { + 0 => Some("MENU7_PIC"), + 3 => Some("MENU4_PIC"), + 7 => Some("MENU8_PIC"), + _ => None, + } + } else { + None + }; + let native = asset.and_then(|name| self.catalog.id(name).ok()); + if let Some(asset) = native { + let image = self.raw(asset)?; + canvas.blit( + &nearest(&image, canvas.scale), + 164 * canvas.scale as i32, + y * canvas.scale as i32, + ); + } else { + self.font.text( + canvas, + 172, + y + 5, + &fit_text(&self.font, label, 124), + if index == self.selected { 50 } else { 100 }, + ); + } + if index == self.selected { + // Native button art must not cover the selection indicator. + canvas.rect(164, y, 136, 1, 50); + canvas.rect(164, y + 1, 3, 13, 50); + } + } + Ok(()) + } + fn hud_asset(&mut self, canvas: &mut Canvas, asset: u32, x: i32, y: i32) -> Result<()> { + let original = self.raw(asset)?; + let sprite = self.cache.get(&original, canvas.scale, true)?; + canvas.blit(sprite, x * canvas.scale as i32, y * canvas.scale as i32); + Ok(()) + } + fn hud_weapon(&mut self, canvas: &mut Canvas, item: &ContentId) -> Result<()> { + let definition = &self.active.registry.items[item]; + let Some(art) = definition.art.clone() else { + return Ok(()); + }; + let owner = self.active.registry.owners[item].clone(); + let source = if let Some(asset) = art + .asset + .filter(|_| !(self.config.hd_mode && art.hd_png.is_some())) + { + self.raw(asset)? + } else { + art_sprite_variant( + &self.catalog, + &self.active.registry, + &owner, + &art, + 0, + self.config.hd_mode, + )? + }; + // Original equipment icons keep their native canvas. Mod equipment + // fits the same slot, including its transparent margins and HD detail. + let logical_w = art.logical_width as usize; + let logical_h = art.logical_height as usize; + let shrink = (18.0 / logical_w as f32) + .min(18.0 / logical_h as f32) + .min(1.0); + let width = ((logical_w as f32 * shrink).round() as usize).max(1) * canvas.scale; + let height = ((logical_h as f32 * shrink).round() as usize).max(1) * canvas.scale; + let mut icon = Sprite::blank(width, height); + for y in 0..height { + for x in 0..width { + icon.pixels[y * width + x] = source.pixels + [(y * source.height / height) * source.width + x * source.width / width]; + } + } + canvas.blit(&icon, 286 * canvas.scale as i32, 2 * canvas.scale as i32); + Ok(()) + } + fn hud(&mut self, canvas: &mut Canvas, world: &World) -> Result<()> { + canvas.rect(0, 0, 16, 200, 0); + canvas.rect(304, 0, 16, 200, 0); + hud_shield_bar(canvas, 8, world.player.super_shield); + hud_shield_bar(canvas, 308, world.player.shield); + // RAP_PrintNum's dollar glyph, one-pixel gap and eight-pixel digits. + self.hud_asset(canvas, 0x1000f, 119, 2)?; + for (position, digit) in format!("{:08}", world.player.credits).bytes().enumerate() { + self.hud_asset( + canvas, + 0x10005 + (digit - b'0') as u32, + 128 + position as i32 * 8, + 2, + )?; + } + if let Some(item) = &world.player.selected { + if world.player.inventory.get(item).is_some_and(|n| *n > 0) { + self.hud_weapon(canvas, item)?; + } + } + // Equipped charge and reserve modules are distinct inventory units. + if world + .player + .inventory + .contains_key(&ContentId::from("base:super_shield")) + { + for module in 0..=world.player.super_shield_reserves.len() { + self.hud_asset(canvas, 0x102e0, 18 + module as i32 * 13, 1)?; + } + } + let bombs = world + .player + .inventory + .get(&ContentId::from("base:mega_bomb")) + .copied() + .unwrap_or(0); + for index in 0..bombs.min(7) { + self.hud_asset(canvas, 0x102df, 18 + index as i32 * 13, 186)?; + } + if bombs > 7 { + self.font + .text(canvas, 18 + 7 * 13, 189, &format!("+{}", bombs - 7), 68); + } + if world + .player + .inventory + .get(&ContentId::from("base:detect")) + .is_some_and(|n| *n > 0) + { + let level = boss_shield_percent(world, &self.active.registry); + if level > 0 { + canvas.rect(109, 191, 102, 8, 74); + canvas.rect(110, 192, level, 6, 68); + } else { + canvas.rect(110 + self.hud_state.sweep as i32, 190, 1, 3, 68); + canvas.rect(209 - self.hud_state.sweep as i32, 190, 1, 3, 68); + } + } + if world.player.shield <= 10 && self.hud_state.blink { + if self.hud_state.damage_flashes > 0 { + let width = self.raw(0x10011)?.width as i32; + self.hud_asset(canvas, 0x10011, (320 - width) / 2, 173)?; + } + let width = self.raw(0x10010)?.width as i32; + self.hud_asset(canvas, 0x10010, (320 - width) / 2, 182)?; + } + Ok(()) + } + fn scene(&mut self, canvas: &mut Canvas) -> Result<()> { + let Some(world) = self.world.clone() else { + return Ok(()); + }; + let mission = self.active.registry.missions[&world.mission].clone(); + let alpha = self.clock.alpha(); + let interp = + |current: i32, previous: i32| previous as f32 + (current - previous) as f32 * alpha; + // Compose a joined terrain layer with a two-pixel gutter before filtering. + let mut terrain = Canvas::new(1); + terrain.image = Sprite { + width: 320, + height: 204, + pixels: vec![0; 320 * 204], + }; + let rows = if mission.columns > 0 { + mission.layout.len() / mission.columns as usize + } else { + 0 + }; + let offset = 200 - rows as i32 * 32 + world.scroll; + for (row, tiles) in mission + .layout + .chunks(mission.columns.max(1) as usize) + .enumerate() + { + let y = offset + row as i32 * 32; + if !(-34..202).contains(&y) { + continue; + } + for (col, index) in tiles.iter().enumerate() { + let n = row * mission.columns as usize + col; + let tile = &mission.tiles[*index]; + let art = if if mission.legacy_map.is_some() { + world.terrain_settled[n] + } else { + world.terrain_dead[n] + } { + tile.destroyed.as_ref().unwrap_or(&tile.art) + } else { + &tile.art + }; + let owner = self.active.registry.owners[&world.mission].clone(); + let mut original = art.clone(); + original.hd_png = None; + self.art( + &mut terrain, + &original, + &owner, + 0, + 16.0 + col as f32 * 32.0, + (y + 2) as f32, + )?; + } + } + let bg = upscale(&terrain.image, canvas.scale)?; + let shift = if world.tick > 0 { alpha - 1.0 } else { 0.0 }; + canvas.blit( + &bg, + 0, + (-2.0 * canvas.scale as f32 + shift * canvas.scale as f32).round() as i32, + ); + if self.config.hd_mode { + // Keep detailed terrain on the presentation canvas. The joined + // SD layer underneath supplies context and fallback at its edges. + for (row, tiles) in mission + .layout + .chunks(mission.columns.max(1) as usize) + .enumerate() + { + let y = offset + row as i32 * 32; + if !(-34..202).contains(&y) { + continue; + } + for (col, index) in tiles.iter().enumerate() { + let n = row * mission.columns as usize + col; + let tile = &mission.tiles[*index]; + let dead = if mission.legacy_map.is_some() { + world.terrain_settled[n] + } else { + world.terrain_dead[n] + }; + let art = if dead { + tile.destroyed.as_ref().unwrap_or(&tile.art) + } else { + &tile.art + }; + if art.hd_png.is_some() { + let owner = self.active.registry.owners[&world.mission].clone(); + self.art( + canvas, + art, + &owner, + 0, + 16.0 + col as f32 * 32.0, + y as f32 + shift, + )?; + } + } + } + } + let dark = matches!( + world.mission.0.as_str(), + "base:sector_2_wave_8" | "base:sector_3_wave_3" + ); + let player = self.catalog.id(if dark { + if world.player_flash { + "FPLAYER_PIC" + } else { + "DPLAYER_PIC" + } + } else { + "LPLAYER_PIC" + })?; + let player_frame = player + world.player.bank.clamp(0, 6) as u32; + for ground in [true, false] { + let mut shadows = 0; + for e in &world.enemies { + let def = self.active.registry.enemies[&e.definition].clone(); + if def.ground != ground + || def + .legacy + .as_ref() + .is_some_and(|legacy| legacy.fields[11] == 0) + { + continue; + } + if shadows >= if ground { 25 } else { 50 } { + break; + } + if let Some(art) = def.art { + let owner = self.active.registry.owners[&e.definition].clone(); + self.shadow( + canvas, + &art, + &owner, + if def.legacy.is_some() { + e.art_frame + } else { + e.frame + }, + interp(e.x, e.px), + interp(e.y, e.py), + ground, + )?; + shadows += 1; + } + } + if !ground && shadows < 50 && world.draw_player { + let mask = self.raw(player_frame)?; + shade_air_shadow( + canvas, + &mask, + interp(world.player.x, world.player.px), + interp(world.player.y, world.player.py), + &self.air_shadow_table, + ); + } + if !ground { + self.effects(canvas, &world, true, alpha)?; + } + for e in &world.enemies { + let def = self.active.registry.enemies[&e.definition].clone(); + if def.ground != ground { + continue; + } + if let Some(art) = def.art { + let owner = self.active.registry.owners[&e.definition].clone(); + self.art( + canvas, + &art, + &owner, + if def.legacy.is_some() { + e.art_frame + } else { + e.frame + }, + interp(e.x, e.px), + interp(e.y, e.py), + )?; + } + } + } + self.projectiles(canvas, &world, false, alpha)?; + for pickup in &world.pickups { + if pickup + .legacy + .as_ref() + .is_some_and(|legacy| legacy.countdown.is_some()) + { + if let Ok(asset) = self.catalog.id("N$_PIC") { + let original = self.raw(asset)?; + let sprite = self.cache.get(&original, canvas.scale, false)?.clone(); + canvas.blit( + &sprite, + pickup.x * canvas.scale as i32, + pickup.y * canvas.scale as i32, + ); + } + continue; + } + if let Some(art) = self.active.registry.items[&pickup.item].art.clone() { + let owner = self.active.registry.owners[&pickup.item].clone(); + self.art( + canvas, + &art, + &owner, + pickup + .legacy + .as_ref() + .map_or((world.tick / 3) as u32, |legacy| legacy.art_frame), + pickup.x as f32, + pickup.y as f32, + )?; + } + } + if world.draw_player { + let s = self.raw(player_frame)?; + let hd = if self.config.hd_mode { + self.hd.sprite(player_frame, canvas.scale, &self.palette)? + } else { + None + }; + let scaled = match hd { + Some(sprite) => sprite, + None => self.cache.get(&s, canvas.scale, false)?.clone(), + }; + canvas.blit( + &scaled, + (interp(world.player.x, world.player.px) * canvas.scale as f32) as i32, + (interp(world.player.y, world.player.py) * canvas.scale as f32) as i32, + ); + } + self.effects(canvas, &world, false, alpha)?; + self.projectiles(canvas, &world, true, alpha)?; + self.hud(canvas, &world)?; + Ok(()) + } + fn draw(&mut self) -> Result { + let mut canvas = Canvas::new(self.config.scale); + canvas.clear(0); + if let Some(movie) = &self.cinematic { + canvas.blit(&nearest(&movie.image, canvas.scale), 0, 0); + return Ok(canvas); + } + if self.world.is_some() { + self.scene(&mut canvas)?; + if self.screen == Screen::Game { + return Ok(canvas); + } + } else if let Ok(id) = self.catalog.id("NEWMAIN_PIC") { + let s = self.raw(id)?; + canvas.blit(&nearest(&s, canvas.scale), 0, 0); + } + let classic = matches!(self.screen, Screen::Main | Screen::Hangar); + canvas.rect( + 12, + 8, + 296, + if classic { 31 } else { 184 }, + closest(&self.palette, [10, 15, 32]), + ); + if classic { + canvas.rect(12, 170, 296, 21, closest(&self.palette, [10, 15, 32])); + } + canvas.rect(12, 8, 296, 1, 100); + canvas.rect(12, 191, 296, 1, 100); + let title = match self.screen { + Screen::Main => "RAPTOR / RUST", + Screen::Hangar => "HANGAR", + Screen::Extras => "HELP / CREDITS", + Screen::PilotForm => { + if self.pilot_form.as_ref().is_some_and(|form| form.creating) { + "REGISTER NEW PILOT" + } else { + "EDIT PILOT ID" + } + } + Screen::Missions => "MISSION SELECT", + Screen::Shop => { + if self.shop_selling { + "HANGAR / SHOP - SELL" + } else { + "HANGAR / SHOP - BUY" + } + } + Screen::ShopDetails => "EQUIPMENT DETAILS", + Screen::Inventory => "INVENTORY / EQUIPMENT", + Screen::Mods => "MOD MANAGER", + Screen::Options => "OPTIONS", + Screen::Pause => "PAUSED", + Screen::Results => "FLIGHT REPORT", + Screen::Help => "FLIGHT CONTROLS", + Screen::Credits => "CREDITS", + Screen::Pilots => "LOAD PILOT", + Screen::LegacyPilots => "IMPORT LEGACY PILOT", + Screen::ConfirmDisable => "ARE YOU SURE?", + Screen::Diagnostics => "MOD / SAVE DIAGNOSTICS", + Screen::Bindings => "KEYBOARD BINDINGS", + Screen::Cinematic => "", + Screen::Game => "", + }; + self.font.text(&mut canvas, 22, 17, title, 50); + self.font.text( + &mut canvas, + 22, + 29, + &format!("PILOT {} ${}", self.pilot.callsign, self.pilot.credits), + 100, + ); + let rows = self.rows(); + let card = if self.screen == Screen::Missions { + self.missions().get(self.selected).and_then(|id| { + self.active.registry.missions[id] + .art + .clone() + .map(|art| (art, self.active.registry.owners[id].clone())) + }) + } else { + None + }; + let shop = self.screen == Screen::Shop; + let first = self.selected.saturating_sub(8); + for (index, row) in rows + .iter() + .enumerate() + .skip(first) + .take(if classic { 0 } else { 10 }) + { + let y = 46 + (index - first) as i32 * 11; + if index == self.selected { + canvas.rect( + 20, + y - 2, + if shop { + 140 + } else if card.is_some() { + 126 + } else if self.screen == Screen::PilotForm { + 214 + } else { + 280 + }, + 10, + closest(&self.palette, [25, 45, 65]), + ); + } + self.font.text( + &mut canvas, + 24, + y, + &if shop { + fit_text(&self.font, row, 132) + } else if card.is_some() { + row.chars().take(20).collect::() + } else { + fit_text( + &self.font, + row, + if self.screen == Screen::PilotForm { + 204 + } else { + 272 + }, + ) + }, + if index == self.selected { 50 } else { 100 }, + ); + } + if classic { + self.classic_menu(&mut canvas)?; + } + if self.screen == Screen::PilotForm { + if let Some(form) = &self.pilot_form { + let portrait = form.portrait; + self.menu_portrait(&mut canvas, portrait, 252, 46)?; + } + for (index, line) in [ + "Name: up to 20 characters", + "Callsign: up to 12 characters", + "ENTER advances to the next field", + "Choose Create / Save to finish", + "Cancel or ESC preserves the current pilot", + ] + .iter() + .enumerate() + { + self.font + .text(&mut canvas, 24, 112 + index as i32 * 10, line, 100); + } + } + if let Some((mut art, owner)) = card { + let height = (150 * art.logical_height / art.logical_width).min(108); + art.logical_width = 150; + art.logical_height = height; + self.art(&mut canvas, &art, &owner, 0, 154.0, 52.0)?; + } + if shop { + self.shop_panel(&mut canvas)?; + } + if self.screen == Screen::Mods { + if self.selected >= 2 { + if let Some(id) = self.mod_ids().get(self.selected - 2) { + let m = &self.discovery.mods[id]; + let dependencies = + dependency_lines(&m.manifest, &self.discovery, &self.pending); + let summary = dependencies + .first() + .cloned() + .unwrap_or_else(|| "Dependencies: none".into()); + self.font.text( + &mut canvas, + 22, + 160, + &summary.chars().take(52).collect::(), + 100, + ); + } + } + self.font.text( + &mut canvas, + 22, + 171, + "ENTER toggle/apply <> priority D/X details", + 50, + ); + } else if self.screen == Screen::Help { + let key = |action: &str| binding_label(&self.config, action); + for (n, line) in [ + format!( + "Move: {} / {} / {} / {}", + key("left"), + key("right"), + key("up"), + key("down") + ), + format!("Fire: {} Special: {}", key("fire"), key("special")), + format!("Cycle: {} Mega bomb: {}", key("cycle"), key("bomb")), + "ESC: pause O: options".into(), + "Mouse / controller selectable in Options".into(), + "ENTER: choose ESC: back".into(), + "Shop: choose BUY / SELL, then an item".into(), + ] + .iter() + .enumerate() + { + self.font + .text(&mut canvas, 24, 49 + n as i32 * 14, line, 100); + } + } else if self.screen == Screen::Credits { + for (n, line) in [ + "Raptor: Cygnus Studios / Apogee", + "Reconstructed engine: nukeykt / skynettx", + "Rust replica: GPL v2 or later", + "FM core: Nuked-OPL3 / Tony Gies", + "Game assets: your original purchased copy", + ] + .iter() + .enumerate() + { + self.font + .text(&mut canvas, 24, 52 + n as i32 * 16, line, 100); + } + } else { + let hint = match self.screen { + Screen::Options => "LEFT/RIGHT adjust ENTER change ESC back", + Screen::Main | Screen::Hangar => "UP/DOWN choose ENTER/A accept ESC/B back", + Screen::PilotForm => "Type ID ENTER next ESC cancel", + Screen::Pause => "Save Pilot stores hangar state, not the flight", + Screen::Shop if self.shop_selling => { + "ENTER sell TAB/Y mode D/X details ESC back" + } + Screen::Shop => "ENTER buy TAB/Y mode D/X details ESC back", + Screen::ShopDetails => "UP/DOWN scroll ESC back to shop", + Screen::Inventory => "ENTER equip S sell ESC back", + Screen::LegacyPilots => "ENTER import to a new Rust slot ESC back", + Screen::ConfirmDisable => "Dependents shown above. ESC cancels", + _ => "ENTER/A choose ESC/B back", + }; + self.font.text(&mut canvas, 22, 171, hint, 50); + } + if !self.message.is_empty() { + self.font.text( + &mut canvas, + 22, + 181, + &fit_text(&self.font, &self.message, 276), + 34, + ); + } + Ok(canvas) + } +} +const ACTIONS: [&str; 8] = [ + "up", "down", "left", "right", "fire", "special", "cycle", "bomb", +]; +fn portrait_asset_id(index: usize) -> i32 { + [0x10116, 0x10119, 0x10117, 0x10118][index.min(3)] +} +fn portrait_index(value: i32) -> usize { + match value { + 0..=3 => value as usize, + 0x10116 => 0, + 0x10119 => 1, + 0x10117 => 2, + 0x10118 => 3, + _ => 0, + } +} +fn owned_items(pilot: &Pilot, registry: &Registry) -> Vec { + let mut ids: Vec<_> = pilot + .inventory + .iter() + .filter(|(id, _)| pilot.item_count(id) > 0 && registry.items.contains_key(*id)) + .map(|(id, _)| id.clone()) + .collect(); + ids.sort_by_key(|id| (registry.items[id].legacy.unwrap_or(u32::MAX), id.clone())); + ids +} +fn sellable(id: &ContentId) -> bool { + id.0 != "base:forward_guns" +} +struct ShopInfo { + name: String, + description: String, + kind: &'static str, + price: u32, + resale: u32, + owned: u32, + slots: usize, + stats: Vec, + buy: String, + sell: String, + usage: &'static str, +} +impl ShopInfo { + fn full_lines(&self) -> Vec { + let mut lines = vec![ + self.name.clone(), + self.kind.into(), + format!("Buy ${} / resale ${}", self.price, self.resale), + format!( + "Owned {} / inventory {} of 20 slots", + self.owned, self.slots + ), + ]; + lines.extend(self.stats.clone()); + lines.push(self.description.clone()); + lines.push(self.usage.into()); + lines.push(format!("BUY: {}", self.buy)); + lines.push(format!("SELL: {}", self.sell)); + lines + } +} +fn shop_info( + pilot: &Pilot, + registry: &Registry, + id: &ContentId, + blocked: Option, +) -> Result { + use crate::content::{Target, WeaponBehavior}; + let item = registry.items.get(id).context("unknown shop item")?; + let (kind, usage) = if item.legacy == Some(11) { + ( + "BOMB", + "Press the bound Mega bomb control. Each blast consumes one bomb.", + ) + } else if item.consumable { + ( + "CONSUMABLE", + if item.special { + "Select this item in Inventory and use the Special control; effects may be scripted." + } else { + "Used by its pickup or scripted behavior; see the item description." + }, + ) + } else if item.special { + ( + "SELECTABLE", + if item.weapon.is_some() { + "Equip in Inventory or cycle specials in flight; fire using Fire or Special." + } else { + "Equip in Inventory or cycle specials in flight; activate using Special." + }, + ) + } else if item.weapon.is_some() { + ( + "ALWAYS EQUIPPED", + "Fires together with the forward guns while owned; no selection needed.", + ) + } else { + ("PASSIVE", "Active while owned; no weapon selection needed.") + }; + let mut stats = Vec::new(); + if let Some(weapon) = item + .weapon + .as_ref() + .and_then(|weapon| registry.weapons.get(weapon)) + { + stats.push(format!( + "Targets: {}", + match weapon.target { + Target::Air => "air", + Target::Ground => "ground", + Target::All => "air + ground", + } + )); + stats.push(if weapon.legacy == Some(10) { + "Damage 1; drains guns".into() + } else { + format!("Damage {} / hit", weapon.damage) + }); + stats.push(format!( + "Cycle {} ticks ({:.2}/s)", + weapon.cooldown, + 70.0 / (3.0 * f64::from(weapon.cooldown.max(1))) + )); + let behavior = match weapon.behavior { + WeaponBehavior::Straight => "Straight", + WeaponBehavior::Homing => "Homing", + WeaponBehavior::Beam => "Beam", + WeaponBehavior::Spread => "Spread", + WeaponBehavior::Bomb => "Blast", + WeaponBehavior::EnergyGrab => "Disables guns", + }; + stats.push(format!( + "{}{}", + behavior, + if weapon.script.is_some() { + " / scripted" + } else { + "" + } + )); + } else if id.0 == "base:super_shield" { + stats.push(format!("Modules {} / 5", pilot.item_count(id))); + stats.push(format!( + "Active charge {}", + pilot.inventory.get(id).copied().unwrap_or(0) + )); + stats.push(format!("Fresh charge {}", item.initial_count)); + stats.push("Reserves equip in order".into()); + } else { + stats.push(format!("Capacity {}", item.max_count)); + stats.push(format!("Purchase adds {}", item.initial_count)); + if item.shield != 0 { + stats.push(format!("Energy effect {:+}", item.shield)); + } + if item.credits != 0 { + stats.push(format!("Credit effect {:+}", item.credits)); + } + if item.script.is_some() { + stats.push("Scripted item effects".into()); + } + } + let eligibility = |selling: bool| { + if let Some(error) = &blocked { + return error.clone(); + } + let mut candidate = pilot.clone(); + let result = if selling { + candidate.sell(id, registry) + } else { + candidate.buy(id, registry) + }; + result + .err() + .map_or_else(|| "Ready".into(), |error| format!("{error:#}")) + }; + Ok(ShopInfo { + name: item.name.clone(), + description: if item.description.trim().is_empty() { + usage.into() + } else { + item.description.clone() + }, + kind, + price: item.price, + resale: item.resale_price(), + owned: pilot.item_count(id), + slots: pilot.inventory_slots(), + stats, + buy: eligibility(false), + sell: eligibility(true), + usage, + }) +} +fn fit_text(font: &BitmapFont, text: &str, width: usize) -> String { + if font.text_width(text) <= width { + return text.into(); + } + let mut clipped = String::new(); + for ch in text.chars() { + let next = format!("{clipped}{ch}..."); + if font.text_width(&next) > width { + break; + } + clipped.push(ch); + } + clipped.push_str("..."); + clipped +} +fn wrap_text(font: &BitmapFont, text: &str, width: usize) -> Vec { + let mut lines = Vec::new(); + let mut line = String::new(); + for word in text.split_whitespace() { + let joined = if line.is_empty() { + word.to_string() + } else { + format!("{line} {word}") + }; + if font.text_width(&joined) <= width { + line = joined; + continue; + } + if !line.is_empty() { + lines.push(std::mem::take(&mut line)); + } + for ch in word.chars() { + if font.text_width(&format!("{line}{ch}")) > width && !line.is_empty() { + lines.push(std::mem::take(&mut line)); + } + line.push(ch); + } + } + if !line.is_empty() { + lines.push(line); + } + lines +} +fn menu_row_at(screen: Screen, selected: usize, rows: usize, x: i32, y: i32) -> Option { + if matches!(screen, Screen::Main | Screen::Hangar) { + if !(164..300).contains(&x) || !(42..170).contains(&y) { + return None; + } + let selection = ((y - 42) / 16) as usize; + return (selection < rows).then_some(selection); + } + let right = if screen == Screen::Shop { 160 } else { 300 }; + if !(20..right).contains(&x) || !(44..154).contains(&y) { + return None; + } + let selection = selected.saturating_sub(8) + ((y - 44) / 11) as usize; + (selection < rows).then_some(selection) +} +fn dependency_lines( + manifest: &modding::Manifest, + discovery: &Discovery, + selected: &[String], +) -> Vec { + if manifest.dependencies.is_empty() { + return vec!["Dependencies: none".into()]; + } + manifest + .dependencies + .iter() + .map(|(id, requirement)| { + let status = if let Some(installed) = discovery.mods.get(id) { + let matches = semver::VersionReq::parse(requirement) + .ok() + .zip(semver::Version::parse(&installed.manifest.version).ok()) + .is_some_and(|(requirement, version)| requirement.matches(&version)); + format!( + "installed {} {}", + installed.manifest.version, + if !matches { + "VERSION MISMATCH" + } else if selected.contains(id) { + "enabled" + } else { + "DISABLED" + } + ) + } else { + "MISSING".into() + }; + format!("Requires {id} {requirement}: {status}") + }) + .collect() +} +fn step_volume(value: u8, direction: i32) -> u8 { + if direction < 0 { + if value == 0 { + 127 + } else { + value.saturating_sub(16) + } + } else if value >= 127 { + 0 + } else { + value.saturating_add(16).min(127) + } +} +fn adjust_option(config: &mut Config, selected: usize, direction: i32) -> bool { + if direction == 0 { + return false; + } + let direction = direction.signum(); + let cycle = |value: i32, length: i32| (value + direction).rem_euclid(length); + match selected { + 0 => config.scale = (cycle(config.scale as i32 - 1, 4) + 1) as usize, + 1 => config.scanlines = !config.scanlines, + 2 => config.fullscreen = !config.fullscreen, + 3 => config.borderless = !config.borderless, + 4 => config.aspect_correct = !config.aspect_correct, + 5 => config.music_volume = step_volume(config.music_volume, direction), + 6 => config.fx_volume = step_volume(config.fx_volume, direction), + 7 => { + let controls = ["keyboard", "mouse", "controller"]; + let current = controls + .iter() + .position(|control| *control == config.control) + .unwrap_or(0); + config.control = controls[cycle(current as i32, 3) as usize].into(); + } + 8 => config.difficulty = cycle(config.difficulty as i32, 4) as u32, + 9 => { + (config.width, config.height) = if (config.width, config.height) == (1280, 960) { + (1920, 1080) + } else { + (1280, 960) + }; + } + 10 => config.hd_mode = !config.hd_mode, + 12 => config.fx_channels = (cycle(config.fx_channels as i32 - 1, 8) + 1) as usize, + _ => return false, + } + true +} +fn binding_matches(config: &Config, action: &str, key: Option) -> bool { + key.is_some_and(|key| binding_keys(config, action).contains(&key)) +} +fn binding_keys(config: &Config, action: &str) -> Vec { + let primary = config + .bindings + .get(action) + .and_then(|value| Scancode::from_name(value)); + let mut keys = primary.into_iter().collect::>(); + if action == "cycle" && primary == Some(Scancode::LAlt) { + // The default cycle control accepts either Alt or Shift, while a + // customized action keeps ownership of any explicitly assigned key. + for alias in [Scancode::RAlt, Scancode::LShift, Scancode::RShift] { + if !config + .bindings + .iter() + .any(|(other, value)| other != "cycle" && Scancode::from_name(value) == Some(alias)) + { + keys.push(alias); + } + } + } + keys +} +fn binding_label(config: &Config, action: &str) -> String { + let keys = binding_keys(config, action); + if keys + == [ + Scancode::LAlt, + Scancode::RAlt, + Scancode::LShift, + Scancode::RShift, + ] + { + "Alt / Shift".into() + } else if keys.is_empty() { + config + .bindings + .get(action) + .cloned() + .unwrap_or_else(|| "unbound".into()) + } else { + keys.into_iter() + .map(Scancode::name) + .collect::>() + .join(" / ") + } +} +#[cfg(test)] +mod binding_tests { + use super::*; + #[test] + fn both_alt_and_shift_cycle_without_using_bombs_or_consumables() { + let config = Config::default(); + assert_eq!(binding_label(&config, "cycle"), "Alt / Shift"); + for key in [ + Scancode::LAlt, + Scancode::RAlt, + Scancode::LShift, + Scancode::RShift, + ] { + assert!(binding_matches(&config, "cycle", Some(key))); + assert!(!binding_matches(&config, "bomb", Some(key))); + assert!(!binding_matches(&config, "special", Some(key))); + } + assert!(binding_matches(&config, "bomb", Some(Scancode::Space))); + assert!(!binding_matches(&config, "cycle", Some(Scancode::Space))); + assert!(binding_matches(&config, "special", Some(Scancode::Z))); + assert!(!binding_matches(&config, "cycle", None)); + } + #[test] + fn cycling_aliases_respect_custom_shift_bindings_and_custom_cycle_keys() { + let mut config = Config::default(); + config.bindings.insert("fire".into(), "Right Shift".into()); + config.bindings.insert("bomb".into(), "Left Shift".into()); + assert!(binding_matches(&config, "fire", Some(Scancode::RShift))); + assert!(binding_matches(&config, "bomb", Some(Scancode::LShift))); + assert!(!binding_matches(&config, "cycle", Some(Scancode::RShift))); + assert!(!binding_matches(&config, "cycle", Some(Scancode::LShift))); + assert!(binding_matches(&config, "cycle", Some(Scancode::RAlt))); + config.bindings.insert("cycle".into(), "Q".into()); + assert!(binding_matches(&config, "cycle", Some(Scancode::Q))); + for key in [ + Scancode::LAlt, + Scancode::RAlt, + Scancode::LShift, + Scancode::RShift, + ] { + assert!(!binding_matches(&config, "cycle", Some(key))); + } + } +} +// A failed load uses a display placeholder; never validate that placeholder +// in place of the existing file when mods are changed. +fn pilot_for_mods( + path: &std::path::Path, + current: &Pilot, + registry: &Registry, + pins: &[modding::ModPin], +) -> Result { + if path.exists() { + Pilot::load(path, registry, pins) + } else { + current.validate(registry, pins)?; + Ok(current.clone()) + } +} +fn restore_scripts(runtime: &mut modding::ScriptRuntime, pilot: &Pilot) { + runtime.states = pilot.script_state.clone(); + runtime.rng = pilot.script_rng.clone(); +} +fn mission_palette(base: &[[u8; 3]; 256], world: Option<&World>) -> [[u8; 3]; 256] { + let mut palette = *base; + let Some(world) = world else { + return palette; + }; + if world.tick < 3 { + return palette; + } + let updates = (world.tick - 1) / 2; + let water = (updates - (updates + 3) / 4) % 5; + let fire = (updates / 2) % 5; + for index in 0..5 { + palette[240 + index] = base[240 + (index + water as usize) % 5]; + palette[245 + index] = base[245 + (index + fire as usize) % 5]; + } + const GLOW: [u8; 20] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]; + for (index, phase) in [(250, 0_u64), (251, 8_u64)] { + let amount = GLOW[((phase + updates - 1) % 20) as usize]; + palette[index] = base[index].map(|value| { + let value = (value >> 2).saturating_sub(amount); + (value << 2) | (value >> 4) + }); + } + if updates >= 2 { + palette[if (updates / 2 - 1) % 2 == 0 { 252 } else { 253 }] = [0; 3]; + } + if !world.palette_blue { + palette[254] = [0; 3]; + } + if (updates - 1) % 6 >= 3 { + palette[255] = [0; 3]; + } + palette +} +fn closest(palette: &[[u8; 3]; 256], rgb: [u8; 3]) -> u16 { + palette + .iter() + .enumerate() + .min_by_key(|(_, c)| { + (0..3) + .map(|i| (c[i] as i32 - rgb[i] as i32).pow(2)) + .sum::() + }) + .unwrap() + .0 as u16 +} +fn viewport(w: u32, h: u32, aspect: bool) -> Rect { + if aspect { + let width = w.min(h * 4 / 3); + let height = h.min(w * 3 / 4); + Rect::new( + ((w - width) / 2) as i32, + ((h - height) / 2) as i32, + width, + height, + ) + } else { + Rect::new(0, 0, w, h) + } +} +pub fn run( + catalog: Catalog, + base: Registry, + paths: Paths, + mods_root: PathBuf, + frames: Option, + screenshot: Option, + preview: Option, + movie_preview: Option, + benchmark: bool, +) -> Result<()> { + let setup_start = Instant::now(); + let mut host = Host::new(catalog, base, paths, mods_root, frames.is_some())?; + if let Some(id) = preview { + if frames.is_some() { + host.new_pilot()?; + } + host.start(id)?; + } + if let Some(kind) = movie_preview { + host.show_movie(kind, Screen::Main)?; + } + let sdl = sdl2::init().map_err(|e| anyhow!(e))?; + let video = sdl.video().map_err(|e| anyhow!(e))?; + let window = video + .window( + "RaptorReplica - Rust", + host.config.width, + host.config.height, + ) + .position_centered() + .resizable() + .allow_highdpi() + .build()?; + let mut canvas = match window.into_canvas().accelerated().present_vsync().build() { + Ok(c) => c, + Err(_) => video + .window( + "RaptorReplica - Rust", + host.config.width, + host.config.height, + ) + .resizable() + .build()? + .into_canvas() + .software() + .build()?, + }; + let creator = canvas.texture_creator(); + let mut texture = creator.create_texture_streaming( + PixelFormatEnum::RGBA32, + (320 * host.config.scale) as u32, + (200 * host.config.scale) as u32, + )?; + let mut applied = (0usize, false, false, 0u32, 0u32); + let mut events = sdl.event_pump().map_err(|e| anyhow!(e))?; + let controllers = sdl.game_controller().map_err(|e| anyhow!(e))?; + let mut controller = (0..controllers.num_joysticks().unwrap_or(0)) + .filter(|index| controllers.is_game_controller(*index)) + .find_map(|index| controllers.open(index).ok()); + let audio_capture = std::env::var_os("RAPTOR_AUDIO_CAPTURE").map(PathBuf::from); + match sdl.audio() { + Ok(audio) => { + let genmidi = host.catalog.named("GENMIDI_OP2")?; + let desired = AudioSpecDesired { + freq: Some(44100), + channels: Some(2), + samples: Some(512), + }; + match audio.open_playback(None, &desired, |spec| { + Mixer::new(spec.freq as u32, &genmidi).expect("validated instrument table") + }) { + Ok(device) => { + let mut device = device; + if let Err(error) = host.sound_bank.preload_originals(&host.catalog) { + host.message = format!("Original sound cache unavailable: {error:#}"); + } + { + let mut mixer = device.lock(); + mixer.set_fx_channels(host.config.fx_channels); + mixer.music_volume = host.config.music_volume; + mixer.fx_volume = host.config.fx_volume; + if audio_capture.is_some() { + let seconds = std::env::var("RAPTOR_AUDIO_CAPTURE_SECONDS") + .ok() + .map(|s| s.parse::()) + .transpose()? + .unwrap_or(15); + mixer.start_capture(seconds)?; + } + } + device.resume(); + host.audio = Some(device) + } + Err(e) => host.message = format!("Audio unavailable: {e}"), + } + } + Err(error) => host.message = format!("Audio unavailable: {error}"), + } + if let Some(music) = host.desired_music { + if let Ok(score) = host.catalog.read(music).and_then(|b| MusScore::decode(&b)) { + if let Some(audio) = &mut host.audio { + audio.lock().play_with_fade(score, true, true); + } + } + } else { + host.music("MAINMENU_MUS"); + } + let mut previous = Instant::now(); + let setup_ms = setup_start.elapsed().as_secs_f64() * 1000.0; + let mut work_times = Vec::new(); + let mut frame_times = Vec::new(); + let mut count = 0u32; + let mut cycle = false; + let mut bomb = false; + let mut quit = false; + let mut text_input_active = false; + while !quit { + let frame_start = Instant::now(); + for event in events.poll_iter() { + match event { + Event::Quit { .. } => quit = true, + Event::ControllerDeviceAdded { which, .. } => { + controller = controllers.open(which).ok(); + } + Event::ControllerDeviceRemoved { which, .. } => { + if controller + .as_ref() + .is_some_and(|c| c.instance_id() == which) + { + controller = (0..controllers.num_joysticks().unwrap_or(0)) + .filter(|index| controllers.is_game_controller(*index)) + .find_map(|index| controllers.open(index).ok()); + } + } + Event::ControllerButtonDown { button, .. } => { + if host.screen == Screen::Cinematic { + host.finish_movie(); + continue; + } + if button == Button::Start { + if let Err(e) = host.back() { + host.message = e.to_string() + } + } else if host.screen != Screen::Game { + match button { + Button::DPadUp => host.move_selection(-1), + Button::DPadDown => host.move_selection(1), + Button::DPadLeft => host.adjust(-1), + Button::DPadRight => host.adjust(1), + Button::X if host.screen == Screen::Shop => host.open_shop_details(), + Button::Y if host.screen == Screen::Shop => { + host.toggle_shop_mode(); + host.play_ui_sound("FX_SWEP"); + } + Button::X if host.screen == Screen::Mods => host.open_diagnostics(), + Button::A => match host.choose() { + Ok(r) => quit = !r, + Err(e) => host.message = format!("{e:#}"), + }, + Button::B => { + if let Err(e) = host.back() { + host.message = e.to_string(); + } + } + _ => {} + } + } else if button == Button::X { + cycle = true + } else if button == Button::Y { + bomb = true + } + } + Event::MouseWheel { y, .. } if host.screen != Screen::Game => { + host.move_selection(y.saturating_neg()); + } + Event::MouseButtonDown { x, y, .. } if host.screen != Screen::Game => { + if host.screen == Screen::Cinematic { + host.finish_movie(); + 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; + if host.screen == Screen::PilotForm + && (252..292).contains(&xx) + && (46..86).contains(&yy) + { + host.selected = 2; + if let Err(error) = host.choose() { + host.message = error.to_string(); + } + continue; + } + if host.screen == Screen::Shop + && (164..302).contains(&xx) + && (43..169).contains(&yy) + { + host.open_shop_details(); + continue; + } + if host.screen == Screen::Mods + && (20..300).contains(&xx) + && (158..178).contains(&yy) + { + host.open_diagnostics(); + continue; + } + if let Some(selection) = + menu_row_at(host.screen, host.selected, host.rows().len(), xx, yy) + { + host.selected = selection; + match host.choose() { + Ok(r) => quit = !r, + Err(e) => host.message = format!("{e:#}"), + } + } + } + Event::TextInput { text, .. } if host.screen == Screen::PilotForm => { + host.type_pilot_text(&text); + } + Event::KeyDown { + keycode: Some(key), + scancode: physical_key, + keymod, + repeat: false, + .. + } => { + if host.screen == Screen::Cinematic { + host.finish_movie(); + continue; + } + if host.screen == Screen::PilotForm + && host + .pilot_form + .as_ref() + .is_some_and(|form| form.field.is_some()) + { + if key == Keycode::Escape { + if let Err(error) = host.back() { + host.message = error.to_string(); + } + continue; + } + if host.pilot_field_key(key) { + continue; + } + } + if let Some(action) = host.awaiting_binding.take() { + if key == Keycode::Escape { + host.message = "Binding change cancelled".into(); + } else if let Some(scancode) = physical_key { + host.config + .bindings + .insert(action.clone(), scancode.name().to_string()); + host.message = format!("{action}: {}", scancode.name()); + if let Err(e) = host.save_config() { + host.message = e.to_string(); + } + } + continue; + } + if host.screen == Screen::Game + && ACTIONS + .iter() + .any(|action| binding_matches(&host.config, action, physical_key)) + { + cycle |= binding_matches(&host.config, "cycle", physical_key); + bomb |= binding_matches(&host.config, "bomb", physical_key); + continue; + } + if key == Keycode::Return + && keymod + .intersects(sdl2::keyboard::Mod::LALTMOD | sdl2::keyboard::Mod::RALTMOD) + { + host.config.fullscreen = !host.config.fullscreen; + continue; + } + if key == Keycode::Escape || key == Keycode::P && host.screen == Screen::Game { + if let Err(e) = host.back() { + host.message = e.to_string() + } + continue; + } + if host.screen == Screen::Game { + match key { + Keycode::O => { + host.previous = Screen::Pause; + host.go(Screen::Options) + } + _ if binding_matches(&host.config, "cycle", physical_key) => { + cycle = true + } + _ if binding_matches(&host.config, "bomb", physical_key) => bomb = true, + _ => {} + } + continue; + } + match key { + Keycode::Up => host.move_selection(-1), + Keycode::Down => host.move_selection(1), + Keycode::Return => match host.choose() { + Ok(r) => quit = !r, + Err(e) => host.message = format!("{e:#}"), + }, + Keycode::D if host.screen == Screen::Shop => host.open_shop_details(), + Keycode::Tab if host.screen == Screen::Shop => { + host.toggle_shop_mode(); + host.play_ui_sound("FX_SWEP"); + } + Keycode::D + if !matches!( + host.screen, + Screen::ConfirmDisable | Screen::PilotForm + ) => + { + host.open_diagnostics() + } + Keycode::N + if host.world.is_none() + && !matches!( + host.screen, + Screen::ConfirmDisable | Screen::PilotForm + ) => + { + if let Err(e) = host.begin_pilot_form(true) { + host.play_ui_sound("FX_WARNING"); + host.message = e.to_string(); + } else { + host.play_ui_sound("FX_SWEP"); + } + } + Keycode::S if host.screen == Screen::Inventory => { + if let Err(e) = host.sell_inventory() { + host.play_ui_sound("FX_WARNING"); + host.message = e.to_string(); + } else { + host.play_ui_sound("FX_SWEP"); + } + } + Keycode::Left => host.adjust(-1), + Keycode::Right => host.adjust(1), + _ => {} + } + } + _ => {} + } + } + let editing = host.screen == Screen::PilotForm + && host + .pilot_form + .as_ref() + .is_some_and(|form| form.field.is_some()); + if editing != text_input_active { + let input = canvas.window().subsystem().text_input(); + if editing { + input.start(); + } else { + input.stop(); + } + text_input_active = editing; + } + let desired = ( + host.config.scale, + host.config.fullscreen, + host.config.borderless, + host.config.width, + host.config.height, + ); + if desired != applied { + if host.config.scale != applied.0 { + texture = creator.create_texture_streaming( + PixelFormatEnum::RGBA32, + (320 * host.config.scale) as u32, + (200 * host.config.scale) as u32, + )?; + } + if let Err(e) = canvas + .window_mut() + .set_fullscreen(if host.config.fullscreen { + FullscreenType::Desktop + } else { + FullscreenType::Off + }) + { + host.message = e; + } + canvas.window_mut().set_bordered(!host.config.borderless); + if !host.config.fullscreen { + let _ = canvas + .window_mut() + .set_size(host.config.width, host.config.height); + } + applied = desired; + } + let now = Instant::now(); + let elapsed = now.duration_since(previous).as_nanos().min(250_000_000) as u64; + let elapsed = if frames.is_some() { + 16_666_667 + } else { + elapsed + }; + previous = now; + let keyboard = events.keyboard_state(); + let pressed = |action: &str| { + binding_keys(&host.config, action) + .into_iter() + .any(|key| keyboard.is_scancode_pressed(key)) + }; + let mut input = Input { + dx: pressed("right") as i32 - pressed("left") as i32, + dy: pressed("down") as i32 - pressed("up") as i32, + fire: pressed("fire") || frames.is_some() && host.screen == Screen::Game, + special: pressed("special"), + cycle, + bomb, + ..Input::default() + }; + if host.config.control == "mouse" { + let mouse = events.mouse_state(); + let (w, h) = canvas.window().size(); + let r = viewport(w, h, host.config.aspect_correct); + input.absolute = Some([ + (mouse.x() - r.x()) * 320 / r.width() as i32 - 16, + (mouse.y() - r.y()) * 200 / r.height() as i32 - 16, + ]); + input.fire = mouse.left(); + input.special = mouse.right(); + } + if host.config.control == "controller" { + if let Some(c) = &controller { + input.dx = if i32::from(c.axis(Axis::LeftX)).abs() > 8000 { + c.axis(Axis::LeftX).signum() as i32 + } else { + 0 + }; + input.dy = if i32::from(c.axis(Axis::LeftY)).abs() > 8000 { + c.axis(Axis::LeftY).signum() as i32 + } else { + 0 + }; + if input.dx == 0 { + input.dx = + c.button(Button::DPadRight) as i32 - c.button(Button::DPadLeft) as i32; + } + if input.dy == 0 { + input.dy = c.button(Button::DPadDown) as i32 - c.button(Button::DPadUp) as i32; + } + input.fire = c.button(Button::A); + input.special = c.button(Button::B); + } + } + if let Err(e) = host.update(input, elapsed) { + host.message = format!("Mission paused: {e:#}"); + host.go(Screen::Pause); + } + cycle = false; + bomb = false; + if let Some(audio) = &mut host.audio { + let mut mixer = audio.lock(); + mixer.set_fx_channels(host.config.fx_channels); + mixer.music_volume = host.config.music_volume; + mixer.fx_volume = host.config.fx_volume; + } + let image = host.draw()?; + let palette = if let Some(movie) = &host.cinematic { + movie.palette(&host.palette) + } else { + mission_palette(&host.palette, host.world.as_ref()) + }; + let rgba = image.rgba(&palette, host.config.scanlines); + 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(); + canvas + .copy(&texture, None, viewport(w, h, host.config.aspect_correct)) + .map_err(|e| anyhow!(e))?; + if frames.is_some_and(|n| count + 1 >= n) { + if let Some(path) = &screenshot { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let presented = canvas + .read_pixels(None, PixelFormatEnum::RGBA32) + .map_err(|e| anyhow!(e))?; + image::save_buffer(path, &presented, w, h, image::ColorType::Rgba8)?; + } + } + let work_ms = frame_start.elapsed().as_secs_f64() * 1000.0; + canvas.present(); + if benchmark && count >= 5 && !frames.is_some_and(|n| count + 1 >= n) { + work_times.push(work_ms); + frame_times.push(frame_start.elapsed().as_secs_f64() * 1000.0); + } + count += 1; + if frames.is_some_and(|n| count >= n) { + quit = true; + } + if frames.is_some() { + std::thread::sleep(Duration::from_millis(1)); + } + } + if benchmark && !work_times.is_empty() { + let (output_width, output_height) = canvas.output_size().map_err(|e| anyhow!(e))?; + work_times.sort_by(f64::total_cmp); + frame_times.sort_by(f64::total_cmp); + let percentile = |values: &[f64], p: usize| values[(values.len() - 1) * p / 100]; + println!( + "benchmark renderer={} size={}x{} scale={} hd={} startup_ms={setup_ms:.2} samples={} work_p50_ms={:.2} work_p95_ms={:.2} frame_p50_ms={:.2} frame_p95_ms={:.2}", + canvas.info().name, + output_width, + output_height, + host.config.scale, + host.config.hd_mode, + work_times.len(), + percentile(&work_times, 50), + percentile(&work_times, 95), + percentile(&frame_times, 50), + percentile(&frame_times, 95) + ); + } + if frames.is_none() { + host.config.save(&host.paths)?; + } + if let Some(path) = audio_capture { + let audio = host + .audio + .as_mut() + .context("audio capture requires an available SDL output device")?; + audio.pause(); + let capture = audio + .lock() + .take_capture() + .context("audio capture was not started")?; + let nonzero = capture.samples.iter().filter(|&&s| s != 0).count(); + println!( + "Audio capture: rate={} frames={} nonzero_samples={} file={}", + capture.rate, + capture.samples.len() / 2, + nonzero, + path.display() + ); + std::fs::write(path, crate::sound::encode_capture(&capture)?)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn ui_fixture() -> (tempfile::TempDir, Host) { + let dir = tempfile::tempdir().unwrap(); + let catalog_root = dir.path().join("catalog"); + std::fs::create_dir(&catalog_root).unwrap(); + std::fs::write(catalog_root.join("PALETTE_DAT"), [0; 768]).unwrap(); + let mut font = vec![0; 772]; + font[..4].copy_from_slice(&1i32.to_le_bytes()); + std::fs::write(catalog_root.join("SYSTEM_FNT"), font).unwrap(); + std::fs::write( + catalog_root.join("INDEX"), + "1 768 PALETTE_DAT\n2 772 SYSTEM_FNT\n", + ) + .unwrap(); + let paths = Paths { + data: dir.path().join("saves"), + config: dir.path().join("config"), + cache: dir.path().join("cache"), + legacy: dir.path().join("legacy"), + }; + let pack = serde_json::from_value(serde_json::json!({ + "items": [ + {"id":"base:forward_guns","name":"Guns","price":0,"legacy":0}, + {"id":"base:empty","name":"Depleted","price":10,"special":true,"legacy":1}, + {"id":"base:fixture","name":"Fixture special","price":40,"special":true,"legacy":2}, + {"id":"base:energy","name":"Energy","price":0,"max_count":100,"legacy":16} + ], + "missions":[{"id":"base:sector_1_wave_1","name":"UI fixture","duration":100}] + })) + .unwrap(); + let mut registry = Registry::default(); + registry.apply(pack, "base").unwrap(); + let host = Host::new( + Catalog::open(catalog_root).unwrap(), + registry, + paths, + dir.path().join("mods"), + false, + ) + .unwrap(); + (dir, host) + } + fn game_input_fixture() -> (tempfile::TempDir, Host) { + let (dir, mut host) = ui_fixture(); + let pack = serde_json::from_value(serde_json::json!({ + "weapons": [{"id":"base:input_bomb","name":"Input bomb","damage":1,"speed":2,"cooldown":1,"behavior":"straight","target":"all"}], + "items": [ + {"id":"base:cycle_a","name":"Special A","price":1,"special":true}, + {"id":"base:cycle_b","name":"Special B","price":1,"special":true}, + {"id":"base:mega_bomb","name":"Bomb","price":1,"max_count":5,"consumable":true,"weapon":"base:input_bomb"} + ] + })).unwrap(); + host.active.registry.apply(pack, "base").unwrap(); + host.pilot.inventory = [ + ("base:energy".into(), 100), + ("base:cycle_a".into(), 1), + ("base:cycle_b".into(), 1), + ("base:mega_bomb".into(), 3), + ] + .into(); + host.pilot.selected = Some("base:cycle_a".into()); + host.world = + Some(World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap()); + host.screen = Screen::Game; + (dir, host) + } + #[test] + fn desktop_action_pulses_accept_consecutive_real_taps() { + let (_dir, mut host) = game_input_fixture(); + let tap = Input { + cycle: true, + bomb: true, + ..Input::default() + }; + host.update(tap, 43_000_000).unwrap(); + let world = host.world.as_ref().unwrap(); + assert_eq!(world.tick, 1); + assert_eq!(world.player.selected, Some("base:cycle_b".into())); + assert_eq!(world.player.inventory[&"base:mega_bomb".into()], 2); + host.update(tap, 43_000_000).unwrap(); + let world = host.world.as_ref().unwrap(); + assert_eq!(world.tick, 2); + assert_eq!(world.player.selected, Some("base:cycle_a".into())); + assert_eq!(world.player.inventory[&"base:mega_bomb".into()], 1); + assert_eq!(world.projectiles.len(), 2); + } + #[test] + fn desktop_action_pulses_wait_for_a_step_and_are_delivered_once() { + let (_dir, mut host) = game_input_fixture(); + host.update( + Input { + cycle: true, + bomb: true, + ..Input::default() + }, + 0, + ) + .unwrap(); + let world = host.world.as_ref().unwrap(); + assert_eq!(world.tick, 0); + assert_eq!(world.player.selected, Some("base:cycle_a".into())); + assert_eq!(world.player.inventory[&"base:mega_bomb".into()], 3); + assert!(host.queued_cycle && host.queued_bomb); + host.update(Input::default(), 129_000_000).unwrap(); + let world = host.world.as_ref().unwrap(); + assert_eq!(world.tick, 3); + assert_eq!(world.player.selected, Some("base:cycle_b".into())); + assert_eq!(world.player.inventory[&"base:mega_bomb".into()], 2); + assert_eq!(world.projectiles.len(), 1); + assert!(!host.queued_cycle && !host.queued_bomb); + host.update(Input::default(), 43_000_000).unwrap(); + assert_eq!( + host.world.as_ref().unwrap().player.inventory[&"base:mega_bomb".into()], + 2 + ); + } + #[test] + fn direct_simulation_still_debounces_held_cycle_and_bomb_buttons() { + let (_dir, mut host) = game_input_fixture(); + let world = host.world.as_mut().unwrap(); + let held = Input { + cycle: true, + bomb: true, + ..Input::default() + }; + for _ in 0..2 { + world + .step(held, &host.active.registry, &mut host.active.scripts) + .unwrap(); + } + assert_eq!(world.player.selected, Some("base:cycle_b".into())); + assert_eq!(world.player.inventory[&"base:mega_bomb".into()], 2); + world + .step( + Input::default(), + &host.active.registry, + &mut host.active.scripts, + ) + .unwrap(); + world + .step(held, &host.active.registry, &mut host.active.scripts) + .unwrap(); + assert_eq!(world.player.selected, Some("base:cycle_a".into())); + assert_eq!(world.player.inventory[&"base:mega_bomb".into()], 1); + } + fn install_dependency_fixture(host: &mut Host) { + for (id, dependencies) in [ + ("dependency", ""), + ("dependent", "[dependencies]\ndependency = \"^1.0\"\n"), + ] { + let root = host.mods_root.join(id); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("mod.toml"), format!("id = \"{id}\"\nname = \"{id}\"\nversion = \"1.0.0\"\napi_version = 1\n{dependencies}")).unwrap(); + std::fs::write(root.join("content.json"), "{}").unwrap(); + } + host.discovery = modding::discover(&host.mods_root); + assert!(host.discovery.errors.is_empty()); + } + #[test] + fn main_and_hangar_expose_all_actions_without_scrolling_and_share_mouse_mapping() { + let (_dir, mut host) = ui_fixture(); + assert_eq!( + host.rows(), + [ + "Return to hangar", + "New pilot", + "Load / import pilot", + "Options", + "Mods", + "Help / credits", + "Watch intro", + "Quit" + ] + ); + for row in 0..host.rows().len() { + assert_eq!( + menu_row_at( + Screen::Main, + 7, + host.rows().len(), + 200, + 47 + row as i32 * 16 + ), + Some(row) + ); + } + assert_eq!(menu_row_at(Screen::Main, 0, 8, 40, 50), None); + host.activate().unwrap(); + assert!(host.screen == Screen::Hangar); + assert_eq!(host.rows()[4], "Save pilot"); + assert_eq!(host.rows().len(), 7); + host.selected = 2; + host.activate().unwrap(); + assert!(host.screen == Screen::Shop); + host.back().unwrap(); + assert!(host.screen == Screen::Hangar); + host.selected = 1; + host.activate().unwrap(); + assert!(host.screen == Screen::Missions); + host.back().unwrap(); + assert!(host.screen == Screen::Hangar); + host.selected = 6; + host.activate().unwrap(); + assert!(host.screen == Screen::Main); + } + #[test] + fn registration_requires_identity_preserves_existing_slots_and_cancels_without_writes() { + let (_dir, mut host) = ui_fixture(); + host.pilot.save(&host.paths.save_path(0)).unwrap(); + let original = std::fs::read(host.paths.save_path(0)).unwrap(); + let pilot_before = serde_json::to_value(&host.pilot).unwrap(); + host.selected = 1; + host.activate().unwrap(); + assert!(host.screen == Screen::PilotForm); + assert!( + host.commit_pilot_form() + .unwrap_err() + .to_string() + .contains("name") + ); + host.type_pilot_text("Aria"); + assert!(host.pilot_field_key(Keycode::Return)); + host.type_pilot_text("ACE"); + host.back().unwrap(); + assert!(host.screen == Screen::Main); + assert_eq!(serde_json::to_value(&host.pilot).unwrap(), pilot_before); + assert_eq!(host.slots(), vec![0]); + host.begin_pilot_form(true).unwrap(); + host.type_pilot_text("Aria"); + host.pilot_field_key(Keycode::Return); + host.type_pilot_text("ACE"); + host.pilot_field_key(Keycode::Return); + assert_eq!(host.selected, 4); + host.selected = 3; + host.adjust(-1); + assert_eq!(host.pilot_form.as_ref().unwrap().difficulty, 1); + assert_eq!(host.config.difficulty, 2); + host.selected = 4; + host.activate().unwrap(); + assert!(host.screen == Screen::Hangar); + assert_eq!(host.slot, 1); + assert_eq!(host.pilot.name, "Aria"); + assert_eq!(host.pilot.callsign, "ACE"); + assert_eq!(host.pilot.credits, 10_000); + assert_eq!(host.pilot.inventory[&ContentId::from("base:energy")], 75); + assert_eq!(host.config.difficulty, 1); + assert_eq!( + host.pilot.legacy_progress.as_ref().unwrap().difficulties, + [1, 1, 1, 2] + ); + let saved = Pilot::load(&host.paths.save_path(1), &host.active.registry, &[]).unwrap(); + assert_eq!(saved.callsign, "ACE"); + assert_eq!(std::fs::read(host.paths.save_path(0)).unwrap(), original); + host.go(Screen::Pilots); + assert!(host.rows()[3].contains("ACE / Aria")); + } + #[test] + fn pilot_id_edit_has_bounded_text_and_saves_only_after_accepting() { + let (_dir, mut host) = ui_fixture(); + host.pilot.save(&host.paths.save_path(0)).unwrap(); + let before = std::fs::read(host.paths.save_path(0)).unwrap(); + host.hangar(); + host.selected = 5; + host.activate().unwrap(); + host.type_pilot_text("ABCDEFGHIJKLMNOPQRSTUVWXYZ\n"); + assert_eq!(host.pilot_form.as_ref().unwrap().name.len(), 20); + host.pilot_field_key(Keycode::Backspace); + assert_eq!(host.pilot_form.as_ref().unwrap().name.len(), 19); + host.pilot_field_key(Keycode::Return); + host.type_pilot_text("abcdefghijklmnop"); + assert_eq!(host.pilot_form.as_ref().unwrap().callsign.len(), 12); + host.pilot_field_key(Keycode::Return); + host.selected = 2; + host.activate().unwrap(); + assert_eq!(std::fs::read(host.paths.save_path(0)).unwrap(), before); + host.selected = 3; + host.activate().unwrap(); + assert!(host.screen == Screen::Hangar); + assert_eq!( + host.pilot.legacy_progress.as_ref().unwrap().portrait, + 0x10119 + ); + assert_eq!(host.pilot.name, "ABCDEFGHIJKLMNOPQRS"); + assert_eq!(host.pilot.callsign, "abcdefghijkl"); + assert_ne!(std::fs::read(host.paths.save_path(0)).unwrap(), before); + assert_eq!(host.slots(), vec![0]); + } + #[test] + fn pilot_id_editor_maps_native_portraits_and_preserves_unchanged_representations() { + for (index, native) in [0x10116, 0x10119, 0x10117, 0x10118].into_iter().enumerate() { + for representation in [native, index as i32] { + let (_dir, mut host) = ui_fixture(); + host.pilot.legacy_progress = Some(crate::persistence::LegacyProgress { + portrait: representation, + sector: 0, + waves: [0; 3], + difficulties: [2; 4], + training: false, + training_completed: true, + }); + host.pilot.save(&host.paths.save_path(0)).unwrap(); + assert_eq!(portrait_index(representation), index); + assert_eq!(portrait_asset_id(index), native); + host.hangar(); + host.begin_pilot_form(false).unwrap(); + assert_eq!(host.pilot_form.as_ref().unwrap().portrait, index); + host.commit_pilot_form().unwrap(); + assert_eq!( + host.pilot.legacy_progress.as_ref().unwrap().portrait, + representation + ); + let saved = + Pilot::load(&host.paths.save_path(0), &host.active.registry, &[]).unwrap(); + assert_eq!(saved.legacy_progress.unwrap().portrait, representation); + } + } + } + #[test] + fn intentional_save_creates_current_slot_and_blocks_incompatible_existing_save() { + let (_dir, mut host) = ui_fixture(); + host.hangar(); + host.pilot.callsign = "ACE".into(); + host.pilot.credits = 4567; + host.active.scripts.states.insert( + "fixture:behavior.rhai".into(), + serde_json::json!({"count":5}), + ); + host.selected = 4; + host.activate().unwrap(); + assert!(host.screen == Screen::Hangar); + assert!(host.message.contains("Saved ACE")); + let saved = Pilot::load(&host.paths.save_path(0), &host.active.registry, &[]).unwrap(); + assert_eq!(saved.credits, 4567); + assert_eq!(saved.script_state["fixture:behavior.rhai"]["count"], 5); + let mut incompatible = saved; + incompatible.mods.push(modding::ModPin { + id: "missing".into(), + version: "1.0.0".into(), + hash: "required".into(), + }); + incompatible.save(&host.paths.save_path(0)).unwrap(); + let before = std::fs::read(host.paths.save_path(0)).unwrap(); + let pilot_before = serde_json::to_value(&host.pilot).unwrap(); + assert!( + host.activate() + .unwrap_err() + .to_string() + .contains("Existing pilot slot") + ); + assert_eq!(std::fs::read(host.paths.save_path(0)).unwrap(), before); + assert_eq!(serde_json::to_value(&host.pilot).unwrap(), pilot_before); + } + #[test] + fn failed_save_keeps_pilot_and_pause_save_retains_hangar_script_snapshot() { + let (_dir, mut host) = ui_fixture(); + host.pilot.script_state.insert( + "fixture:behavior.rhai".into(), + serde_json::json!({"count":2}), + ); + host.world = + Some(World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap()); + host.world.as_mut().unwrap().player.credits = 999_999; + host.active.scripts.states.insert( + "fixture:behavior.rhai".into(), + serde_json::json!({"count":999}), + ); + host.go(Screen::Pause); + host.selected = 3; + host.activate().unwrap(); + let saved = Pilot::load(&host.paths.save_path(0), &host.active.registry, &[]).unwrap(); + assert_eq!(saved.credits, host.pilot.credits); + assert_ne!(saved.credits, host.world.as_ref().unwrap().player.credits); + assert_eq!(saved.script_state["fixture:behavior.rhai"]["count"], 2); + assert!(host.message.contains("hangar state only")); + host.world = None; + host.paths.data = host.paths.data.join("blocked"); + std::fs::write(&host.paths.data, "not a directory").unwrap(); + let before = serde_json::to_value(&host.pilot).unwrap(); + assert!(host.save().is_err()); + assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before); + } + #[test] + fn settings_have_a_visible_save_action_and_preserve_return_context() { + let (_dir, mut host) = ui_fixture(); + host.previous = Screen::Main; + host.go(Screen::Options); + assert_eq!(host.rows()[13], "Save settings"); + host.config.fx_volume = 127; + host.selected = 13; + host.activate().unwrap(); + assert!(host.screen == Screen::Options); + assert_eq!(Config::load(&host.paths).unwrap().fx_volume, 127); + assert_eq!(host.message, "Settings saved"); + host.selected = 14; + host.activate().unwrap(); + assert!(host.screen == Screen::Main); + } + #[test] + fn diagnostics_preserve_options_return_target_and_do_not_create_binding_loops() { + let (_dir, mut host) = ui_fixture(); + for origin in [Screen::Main, Screen::Pause] { + host.previous = origin; + host.go(Screen::Options); + host.open_diagnostics(); + host.back().unwrap(); + assert!(host.screen == Screen::Options); + host.back().unwrap(); + assert!(host.screen == origin); + host.go(Screen::Options); + host.selected = 11; + host.activate().unwrap(); + host.open_diagnostics(); + host.back().unwrap(); + assert!(host.screen == Screen::Bindings); + host.back().unwrap(); + assert!(host.screen == Screen::Options); + host.back().unwrap(); + assert!(host.screen == origin); + } + } + #[test] + fn inventory_actions_follow_visible_owned_rows() { + let (_dir, mut host) = ui_fixture(); + host.pilot.inventory.insert("base:empty".into(), 0); + host.pilot.inventory.insert("base:fixture".into(), 1); + host.pilot.inventory.insert("base:missing".into(), 1); + host.go(Screen::Inventory); + assert_eq!( + host.inventory_ids(), + vec![ + ContentId::from("base:forward_guns"), + "base:fixture".into(), + "base:energy".into() + ] + ); + assert!(host.rows()[1].starts_with("Fixture special")); + host.selected = 1; + let before = host.pilot.inventory.clone(); + assert!(host.activate().is_err()); // Unavailable saved equipment still blocks edits. + assert_eq!(host.pilot.inventory, before); + host.pilot + .inventory + .remove(&ContentId::from("base:missing")); + host.activate().unwrap(); + assert_eq!(host.pilot.selected, Some("base:fixture".into())); + host.sell_inventory().unwrap(); + assert_eq!(host.pilot.credits, 20); + assert_eq!(host.pilot.selected, None); + assert!(host.rows()[1].starts_with("Energy")); + } + #[test] + fn volume_options_reach_mute_and_maximum_in_both_directions() { + let mut config = Config::default(); + for expected in [101, 117, 127, 0] { + assert!(adjust_option(&mut config, 5, 1)); + assert_eq!(config.music_volume, expected); + } + for expected in [69, 53, 37, 21, 5, 0, 127] { + assert!(adjust_option(&mut config, 6, -1)); + assert_eq!(config.fx_volume, expected); + } + config.scale = 1; + adjust_option(&mut config, 0, -1); + assert_eq!(config.scale, 4); + config.fx_channels = 1; + adjust_option(&mut config, 12, -1); + assert_eq!(config.fx_channels, 8); + assert!(!adjust_option(&mut config, 11, 1)); // Bindings opens a separate screen. + } + #[test] + fn shop_sale_is_accessible_and_failed_save_keeps_pilot_unchanged() { + let (_dir, mut host) = ui_fixture(); + host.pilot.inventory.insert("base:fixture".into(), 1); + host.go(Screen::Shop); + host.activate().unwrap(); // Visible buy/sell mode entry. + assert!(host.shop_selling); + assert_eq!( + host.shop_ids(), + vec![ContentId::from("base:fixture"), "base:energy".into()] + ); + host.selected = 1; + std::fs::write(&host.paths.data, "blocked save directory").unwrap(); + let before = serde_json::to_value(&host.pilot).unwrap(); + assert!(host.activate().is_err()); + assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before); + std::fs::remove_file(&host.paths.data).unwrap(); + host.activate().unwrap(); + assert_eq!(host.pilot.credits, 20); + assert_eq!(host.shop_ids(), vec![ContentId::from("base:energy")]); + } + #[test] + fn legacy_import_uses_a_separate_free_slot_and_retains_original_files() { + let (_dir, mut host) = ui_fixture(); + host.pilot.callsign = "EXISTING".into(); + host.pilot.save(&host.paths.save_path(0)).unwrap(); + let existing = std::fs::read(host.paths.save_path(0)).unwrap(); + let mut header = vec![0u8; 88]; + header[20..28].copy_from_slice(b"IMPORTED"); + header[36..40].copy_from_slice(&1234i32.to_le_bytes()); + header[40..44].copy_from_slice(&(-1i32).to_le_bytes()); + let key = b"CASTLE"; + let mut index = 25 % key.len(); + let mut previous = key[index]; + let source = header + .into_iter() + .map(|clear| { + let byte = clear.wrapping_add(key[index]).wrapping_add(previous); + previous = byte; + index = (index + 1) % key.len(); + byte + }) + .collect::>(); + std::fs::create_dir(&host.paths.legacy).unwrap(); + let path = host.paths.legacy.join("CHAR0007.FIL"); + std::fs::write(&path, &source).unwrap(); + host.import_pilot(&path).unwrap(); + assert_eq!(host.slot, 1); + assert_eq!(host.pilot.callsign, "IMPORTED"); + assert_eq!(host.pilot.credits, 1234); + assert_eq!(std::fs::read(path).unwrap(), source); + assert_eq!(std::fs::read(host.paths.save_path(0)).unwrap(), existing); + assert_eq!( + Pilot::load(&host.paths.save_path(1), &host.active.registry, &[]) + .unwrap() + .callsign, + "IMPORTED" + ); + assert_eq!(host.slots(), vec![0, 1]); + } + #[test] + fn selecting_a_mod_selects_its_installed_dependency_without_applying() { + let (_dir, mut host) = ui_fixture(); + install_dependency_fixture(&mut host); + host.go(Screen::Mods); + host.selected = host + .mod_ids() + .iter() + .position(|id| id == "dependent") + .unwrap() + + 2; + host.activate().unwrap(); + assert_eq!(host.pending, vec!["dependency", "dependent"]); + assert!(host.active.pins.is_empty()); + assert!(host.config.enabled_mods.is_empty()); + assert!( + dependency_lines( + &host.discovery.mods["dependent"].manifest, + &host.discovery, + &host.pending + )[0] + .contains("installed 1.0.0 enabled") + ); + } + #[test] + fn disabling_a_required_mod_needs_confirmation_and_back_preserves_selection() { + let (_dir, mut host) = ui_fixture(); + install_dependency_fixture(&mut host); + host.pending = vec!["dependency".into(), "dependent".into()]; + host.active = ActiveMods::build( + &host.base, + &host.discovery, + &host.pending, + Some(&host.catalog), + ) + .unwrap(); + host.pilot.mods = host.active.pins.clone(); + host.config.enabled_mods = host.pending.clone(); + host.world = + Some(World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap()); + let active_pins = host.active.pins.clone(); + host.go(Screen::Mods); + host.selected = 2; + host.activate().unwrap(); + assert!(host.screen == Screen::ConfirmDisable); + assert_eq!(host.selected, 1); // Cancel is initially selected. + assert_eq!(host.pending, vec!["dependency", "dependent"]); + assert_eq!(host.pending_disable.as_ref().unwrap().removed.len(), 2); + host.back().unwrap(); + assert_eq!(host.pending, vec!["dependency", "dependent"]); + host.selected = 2; + host.activate().unwrap(); + host.activate().unwrap(); // The selected Cancel action also preserves both packages. + assert_eq!(host.pending, vec!["dependency", "dependent"]); + host.selected = 2; + host.activate().unwrap(); + host.selected = 0; + host.activate().unwrap(); + assert!(host.pending.is_empty()); + assert_eq!(host.active.pins, active_pins); // Mission content stays active until main menu. + assert_eq!(host.config.enabled_mods, vec!["dependency", "dependent"]); + assert!(host.world.is_some()); + assert!(host.pending_disable.is_none()); + assert!(host.screen == Screen::Mods); + } + #[test] + fn missing_dependency_selection_fails_without_changing_pending_mods() { + let (_dir, mut host) = ui_fixture(); + install_dependency_fixture(&mut host); + host.discovery.mods.remove("dependency"); + host.go(Screen::Mods); + host.selected = 2; + let error = host.activate().unwrap_err().to_string(); + assert!(error.contains("dependency")); + assert!(host.pending.is_empty()); + assert!(host.active.pins.is_empty()); + assert!(host.screen == Screen::Mods); + } + #[test] + fn shop_information_uses_registry_roles_targets_prices_and_trigger_cadence() { + let (_dir, mut host) = ui_fixture(); + let weapon: crate::content::WeaponDef = serde_json::from_value(serde_json::json!({ + "id":"base:fixture_shot","name":"Fixture projectile","damage":4,"speed":8,"cooldown":9,"behavior":"homing","target":"ground","script":"behavior.rhai" + })).unwrap(); + host.active + .registry + .weapons + .insert(weapon.id.clone(), weapon); + let item = host + .active + .registry + .items + .get_mut(&ContentId::from("base:fixture")) + .unwrap(); + item.weapon = Some("base:fixture_shot".into()); + item.description = "A controlled test weapon.".into(); + host.pilot.credits = 40; + let before = serde_json::to_value(&host.pilot).unwrap(); + let info = host.shop_info(&"base:fixture".into()).unwrap(); + assert_eq!(info.kind, "SELECTABLE"); + assert_eq!((info.price, info.resale, info.owned), (40, 20, 0)); + assert_eq!(info.description, "A controlled test weapon."); + assert_eq!( + info.stats, + vec![ + "Targets: ground", + "Damage 4 / hit", + "Cycle 9 ticks (2.59/s)", + "Homing / scripted" + ] + ); + assert_eq!(info.buy, "Ready"); + assert!(info.sell.contains("not owned")); + assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before); // Eligibility only previews an edit. + host.active + .registry + .items + .get_mut(&ContentId::from("base:fixture")) + .unwrap() + .special = false; + assert_eq!( + host.shop_info(&"base:fixture".into()).unwrap().kind, + "ALWAYS EQUIPPED" + ); + host.active + .registry + .items + .get_mut(&ContentId::from("base:fixture")) + .unwrap() + .consumable = true; + assert_eq!( + host.shop_info(&"base:fixture".into()).unwrap().kind, + "CONSUMABLE" + ); + host.active + .registry + .weapons + .get_mut(&ContentId::from("base:fixture_shot")) + .unwrap() + .legacy = Some(10); + assert_eq!( + host.shop_info(&"base:fixture".into()).unwrap().stats[1], + "Damage 1; drains guns" + ); + } + #[test] + fn shop_reports_super_shield_modules_separately_from_active_charge() { + let (_dir, mut host) = ui_fixture(); + let item: crate::content::ItemDef = serde_json::from_value(serde_json::json!({ + "id":"base:super_shield","name":"Super Shield","price":1000,"initial_count":100,"max_count":100,"legacy":15 + })).unwrap(); + host.active.registry.items.insert(item.id.clone(), item); + host.pilot.inventory.insert("base:super_shield".into(), 40); + host.pilot.super_shield_reserves = vec![100, 75]; + host.pilot.credits = 1000; + let info = host.shop_info(&"base:super_shield".into()).unwrap(); + assert_eq!(info.owned, 3); + assert_eq!(info.slots, 5); + assert_eq!(info.stats[0], "Modules 3 / 5"); + assert_eq!(info.stats[1], "Active charge 40"); + assert_eq!(info.buy, "Ready"); + assert!(info.sell.contains("fully charged")); + host.pilot.inventory.insert("base:super_shield".into(), 0); + assert!( + host.inventory_ids() + .contains(&ContentId::from("base:super_shield")) + ); + assert_eq!( + host.shop_info(&"base:super_shield".into()).unwrap().owned, + 3 + ); + } + #[test] + fn shop_detail_navigation_preserves_item_mode_and_blocks_preview_purchase_clicks() { + let (_dir, mut host) = ui_fixture(); + host.pilot.inventory.insert("base:fixture".into(), 1); + host.go(Screen::Shop); + host.activate().unwrap(); + host.selected = 1; + let before = serde_json::to_value(&host.pilot).unwrap(); + host.open_shop_details(); + assert!(host.screen == Screen::ShopDetails); + assert!(host.rows().iter().any(|line| line.contains("resale"))); + host.back().unwrap(); + assert!(host.screen == Screen::Shop); + assert!(host.shop_selling); + assert_eq!(host.selected, 1); + assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before); + 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)); + } + #[test] + #[ignore = "requires original catalog; writes ignored menu usability captures"] + fn classic_main_hangar_registration_and_explicit_save_visual_capture() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let catalog = Catalog::open(root.parent().unwrap().join("data")).unwrap(); + let registry = Registry::base(&catalog).unwrap(); + let temp = tempfile::tempdir().unwrap(); + let paths = Paths { + data: temp.path().join("saves"), + config: temp.path().join("config"), + cache: temp.path().join("cache"), + legacy: temp.path().join("legacy"), + }; + let mut host = + Host::new(catalog, registry, paths, temp.path().join("mods"), false).unwrap(); + host.config.scale = 4; + let output = root.parent().unwrap().join(".reference/ui-menu"); + std::fs::create_dir_all(&output).unwrap(); + let capture = |host: &mut Host, name: &str| { + let before = serde_json::to_value(&host.pilot).unwrap(); + let canvas = host.draw().unwrap(); + image::save_buffer( + output.join(format!("{name}.png")), + &canvas.rgba(&host.palette, false), + canvas.image.width as u32, + canvas.image.height as u32, + image::ColorType::Rgba8, + ) + .unwrap(); + assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before); + }; + for (selected, name) in [ + (0, "main-classic"), + (3, "main-options-selected"), + (7, "main-quit-selected"), + ] { + host.selected = selected; + let canvas = host.draw().unwrap(); + assert_eq!( + canvas.image.pixels[(43 + selected * 16) * 4 * canvas.image.width + 164 * 4], + 50 + ); + capture(&mut host, name); + } + host.selected = 1; + host.activate().unwrap(); + host.type_pilot_text("Aria"); + host.pilot_field_key(Keycode::Return); + host.type_pilot_text("ACE"); + capture(&mut host, "register-pilot"); + host.pilot_field_key(Keycode::Return); + host.activate().unwrap(); + host.message.clear(); + host.selected = 4; + capture(&mut host, "hangar-save-visible"); + host.activate().unwrap(); + assert!(host.paths.save_path(host.slot).exists()); + capture(&mut host, "save-confirmation"); + host.selected = 5; + host.activate().unwrap(); + capture(&mut host, "edit-pilot-id"); + host.back().unwrap(); + host.go(Screen::Pilots); + host.message.clear(); + assert!(host.rows()[2].contains("ACE / Aria")); + capture(&mut host, "pilot-load-import"); + host.previous = Screen::Main; + host.go(Screen::Options); + host.message.clear(); + host.selected = 13; + capture(&mut host, "settings-save"); + host.start(host.pilot.mission.clone()).unwrap(); + host.go(Screen::Pause); + host.message.clear(); + host.selected = 3; + capture(&mut host, "pause-save-hangar-state"); + } + #[test] + #[ignore = "requires local original catalog and supplied mod PNGs; writes ignored shop captures"] + fn shop_original_and_mod_art_details_capture_without_editing_pilots() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let catalog_root = std::env::var_os("RAPTOR_TEST_DATA") + .map(PathBuf::from) + .unwrap_or_else(|| root.parent().unwrap().join("data")); + let catalog = Catalog::open(catalog_root).unwrap(); + let registry = Registry::base(&catalog).unwrap(); + let temp = tempfile::tempdir().unwrap(); + let paths = Paths { + data: temp.path().join("saves"), + config: temp.path().join("config"), + cache: temp.path().join("cache"), + legacy: temp.path().join("legacy"), + }; + let mut host = Host::new(catalog, registry, paths, root.join("mods"), true).unwrap(); + let enabled = [ + "pulse_cannon".into(), + "seeker_swarm".into(), + "ion_lance".into(), + ]; + host.active = + ActiveMods::build(&host.base, &host.discovery, &enabled, Some(&host.catalog)).unwrap(); + host.pilot.credits = 4_000_000; + host.config.scale = 4; + let output = root.parent().unwrap().join(".reference/ui-shop"); + std::fs::create_dir_all(&output).unwrap(); + let capture = |host: &mut Host, name: &str| { + host.message.clear(); + let before = serde_json::to_value(&host.pilot).unwrap(); + let canvas = host.draw().unwrap(); + let rgba = canvas.rgba(&host.palette, false); + image::save_buffer( + output.join(format!("{name}.png")), + &rgba, + canvas.image.width as u32, + canvas.image.height as u32, + image::ColorType::Rgba8, + ) + .unwrap(); + assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before); + assert!(!host.paths.data.exists()); + rgba + }; + for (legacy, name) in [ + (1, "original-plasma"), + (5, "original-air-missile"), + (9, "original-ground-bomb"), + ] { + let id = host + .active + .registry + .items + .values() + .find(|item| item.legacy == Some(legacy)) + .unwrap() + .id + .clone(); + host.go(Screen::Shop); + host.selected = host + .shop_ids() + .iter() + .position(|candidate| candidate == &id) + .unwrap() + + 1; + let info = host.shop_info(&id).unwrap(); + assert!(!info.description.is_empty()); + assert_eq!(info.buy, "Ready"); + capture(&mut host, name); + } + for namespace in ["pulse_cannon", "seeker_swarm", "ion_lance"] { + let id = ContentId::from(format!("{namespace}:equipment").as_str()); + host.go(Screen::Shop); + host.selected = host + .shop_ids() + .iter() + .position(|candidate| candidate == &id) + .unwrap() + + 1; + assert!( + host.active.registry.items[&id] + .art + .as_ref() + .unwrap() + .hd_png + .is_some() + ); + assert!(!host.shop_info(&id).unwrap().description.is_empty()); + host.config.hd_mode = false; + let sd = capture(&mut host, &format!("{namespace}-sd")); + host.config.hd_mode = true; + let hd = capture(&mut host, &format!("{namespace}-hd")); + assert_ne!( + sd, hd, + "supplied SD and HD equipment previews should differ" + ); + host.open_shop_details(); + capture(&mut host, &format!("{namespace}-full-details")); + } + let id = ContentId::from("base:super_shield"); + host.pilot.inventory.insert(id.clone(), 40); + host.pilot.super_shield_reserves = vec![100, 100]; + host.go(Screen::Shop); + host.shop_selling = true; + host.selected = host + .shop_ids() + .iter() + .position(|candidate| candidate == &id) + .unwrap() + + 1; + assert_eq!(host.shop_info(&id).unwrap().owned, 3); + assert!(host.shop_info(&id).unwrap().sell.contains("fully charged")); + capture(&mut host, "super-shield-partial-resale"); + println!("Shop captures: {}", output.display()); + } + #[test] + fn disabling_a_single_unneeded_mod_toggles_without_confirmation() { + let (_dir, mut host) = ui_fixture(); + install_dependency_fixture(&mut host); + host.pending = vec!["dependency".into()]; + host.go(Screen::Mods); + host.selected = 2; + host.activate().unwrap(); + assert!(host.pending.is_empty()); + assert!(host.pending_disable.is_none()); + assert!(host.screen == Screen::Mods); + assert!(host.active.pins.is_empty()); + } + #[test] + fn disabling_a_dependent_lists_its_unused_dependency_before_removal() { + let (_dir, mut host) = ui_fixture(); + install_dependency_fixture(&mut host); + host.pending = vec!["dependency".into(), "dependent".into()]; + host.go(Screen::Mods); + host.selected = host + .mod_ids() + .iter() + .position(|id| id == "dependent") + .unwrap() + + 2; + host.activate().unwrap(); + assert!(host.screen == Screen::ConfirmDisable); + let removed = &host.pending_disable.as_ref().unwrap().removed; + assert!(removed.iter().any(|id| id == "dependency")); + assert!(removed.iter().any(|id| id == "dependent")); + assert_eq!(host.pending, vec!["dependency", "dependent"]); + host.selected = 0; + host.activate().unwrap(); + assert!(host.pending.is_empty()); + } + #[test] + fn hud_shield_ramp_matches_native_fixed_point_colors_and_clear_rows() { + for scale in [1, 2, 3, 4] { + for amount in [-1, 0, 10, 50, 100, 120] { + let mut canvas = Canvas::new(scale); + canvas.clear(37); + hud_shield_bar(&mut canvas, 308, amount); + let mut accumulator = 0_u32; + for level in 0..100 { + let expected = if level < amount.clamp(0, 100) { + 74 - (accumulator >> 16) as u16 + } else { + 0 + }; + for y in (199 - level * 2) as usize * scale..(200 - level * 2) as usize * scale + { + for x in 308 * scale..312 * scale { + assert_eq!(canvas.image.pixels[y * canvas.image.width + x], expected); + } + } + accumulator += (9 << 16) / 100; + } + assert_eq!( + canvas.image.pixels[199 * scale * canvas.image.width + 307 * scale], + 37 + ); + assert_eq!( + canvas.image.pixels[198 * scale * canvas.image.width + 308 * scale], + 37 + ); + assert_eq!( + canvas + .image + .pixels + .iter() + .filter(|&&p| (66..=74).contains(&p)) + .count(), + amount.clamp(0, 100) as usize * 4 * scale * scale + ); + } + } + } + #[test] + fn hud_warning_latches_follow_steps_and_ignore_repeated_render_observations() { + let (registry, pilot) = fixture(); + let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); + let mut state = HudState::new(&world); + let before = world.rng.seed; + world.player.shield = 9; + for tick in 1..=32 { + world.tick = tick; + state.observe(&world, ®istry); + let snapshot = (state.blink, state.damage_flashes, state.sweep); + for _ in 0..5 { + state.observe(&world, ®istry); + } + assert_eq!((state.blink, state.damage_flashes, state.sweep), snapshot); + assert_eq!(state.blink, (tick / 8) % 2 == 0); + assert_eq!( + state.damage_flashes, + if tick < 16 { + 2 + } else if tick < 32 { + 1 + } else { + 0 + } + ); + } + assert_eq!(world.rng.seed, before); + world.player.shield = 50; + for tick in 33..=48 { + world.tick = tick; + state.observe(&world, ®istry); + } + assert!( + state.blink, + "healthy shields leave the low-energy blink latch alone" + ); + } + #[test] + fn hud_detector_averages_visible_boss_health_and_respects_owned_equipment() { + let (mut registry, pilot) = fixture(); + for (id, health) in [("test:boss_a", 100), ("test:boss_b", 200)] { + let enemy: crate::content::EnemyDef = serde_json::from_value(serde_json::json!({ + "id":id,"name":id,"health":health,"boss":true + })) + .unwrap(); + registry.enemies.insert(enemy.id.clone(), enemy); + } + let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); + world + .spawn(&"test:boss_a".into(), 100, -17, ®istry) + .unwrap(); + world.enemies[0].health = 50; + assert_eq!(boss_shield_percent(&world, ®istry), 0); + world.enemies[0].y = -16; + assert_eq!(boss_shield_percent(&world, ®istry), 50); + world + .spawn(&"test:boss_b".into(), 100, 40, ®istry) + .unwrap(); + world.enemies[1].health = 50; + assert_eq!(boss_shield_percent(&world, ®istry), 37); + let mut state = HudState::new(&world); + world.tick = 1; + state.observe(&world, ®istry); + assert_eq!(state.sweep, 0, "detector is unavailable without equipment"); + world.player.inventory.insert("base:detect".into(), 1); + world.enemies.clear(); + world.tick = 2; + state.observe(&world, ®istry); + assert_eq!(state.sweep, 1); + state.observe(&world, ®istry); + assert_eq!(state.sweep, 1); + for tick in 3..=51 { + world.tick = tick; + state.observe(&world, ®istry); + } + assert_eq!(state.sweep, 0); + } + #[test] + #[ignore = "requires local original catalog; writes ignored gameplay HUD captures"] + fn hud_original_art_and_mod_icons_keep_native_slots_and_simulation_unchanged() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let catalog = Catalog::open(root.parent().unwrap().join("data")).unwrap(); + let registry = Registry::base(&catalog).unwrap(); + let temp = tempfile::tempdir().unwrap(); + let paths = Paths { + data: temp.path().join("saves"), + config: temp.path().join("config"), + cache: temp.path().join("cache"), + legacy: temp.path().join("legacy"), + }; + let mut host = Host::new(catalog, registry, paths, temp.path().join("mods"), true).unwrap(); + host.config.scale = 4; + host.pilot = Pilot::demo(1, &host.active.registry).unwrap(); + let mission = host.pilot.mission.clone(); + host.start(mission).unwrap(); + let mut world = host.world.take().unwrap(); + world.player.credits = 12345678; + world.player.shield = 50; + let selected = host + .active + .registry + .items + .values() + .find(|item| item.legacy == Some(5)) + .unwrap() + .id + .clone(); + world.player.selected = Some(selected.clone()); + world.player.super_shield_reserves = vec![31, 100]; + host.hud_state = HudState::new(&world); + let snapshot = serde_json::to_value(&world).unwrap(); + let mut canvas = Canvas::new(4); + canvas.clear(37); + host.hud(&mut canvas, &world).unwrap(); + let digits: Vec<_> = (0..11) + .map(|digit| host.catalog.sprite(0x10005 + digit).unwrap()) + .collect(); + let mut expected = Canvas::new(4); + expected.clear(37); + expected.blit(&nearest(&digits[10], 4), 119 * 4, 2 * 4); + for (position, digit) in "12345678".bytes().enumerate() { + expected.blit( + &nearest(&digits[(digit - b'0') as usize], 4), + (128 + position as i32 * 8) * 4, + 2 * 4, + ); + } + for y in 2 * 4..10 * 4 { + for x in 119 * 4..192 * 4 { + assert_eq!( + canvas.image.pixels[y * canvas.image.width + x], + expected.image.pixels[y * expected.image.width + x] + ); + } + } + assert_eq!(serde_json::to_value(&world).unwrap(), snapshot); + let original_icon = host.active.registry.items[&selected] + .art + .as_ref() + .unwrap() + .asset + .unwrap(); + let native = host.catalog.sprite(original_icon).unwrap(); + for y in 0..native.height * 4 { + for x in 0..native.width * 4 { + let pixel = native.pixels[y / 4 * native.width + x / 4]; + assert_eq!( + canvas.image.pixels[(2 * 4 + y) * canvas.image.width + 286 * 4 + x], + if pixel == crate::assets::TRANSPARENT { + 37 + } else { + pixel + } + ); + } + } + // Supplied SD/HD artwork uses its registry owner and fits the native + // 18px equipment slot, rather than painting over the right shield bar. + let mut supplied = image::RgbaImage::new(24, 24); + let rgb = host.palette[245]; + for y in 4..20 { + for x in 4..20 { + supplied.put_pixel(x, y, image::Rgba([rgb[0], rgb[1], rgb[2], 255])); + } + } + supplied.save(temp.path().join("equipment.png")).unwrap(); + image::imageops::resize(&supplied, 192, 192, image::imageops::FilterType::Nearest) + .save(temp.path().join("equipment-hd.png")) + .unwrap(); + let pack:crate::content::ContentPack = serde_json::from_value(serde_json::json!({ + "items":[{"id":"hud:equipment","name":"Supplied HUD icon","price":0, + "art":{"png":"equipment.png","hd_png":"equipment-hd.png","logical_width":24,"logical_height":24}}] + })).unwrap(); + host.active.registry.apply(pack, "hud").unwrap(); + host.active + .registry + .asset_roots + .insert("hud".into(), temp.path().to_owned()); + for hd in [false, true] { + host.config.hd_mode = hd; + let mut mod_icon = Canvas::new(4); + mod_icon.clear(37); + host.hud_weapon(&mut mod_icon, &"hud:equipment".into()) + .unwrap(); + assert!(mod_icon.image.pixels.iter().any(|&pixel| pixel != 37)); + for (index, &pixel) in mod_icon.image.pixels.iter().enumerate() { + if pixel != 37 { + assert!((286 * 4..304 * 4).contains(&(index % mod_icon.image.width))); + assert!((2 * 4..20 * 4).contains(&(index / mod_icon.image.width))); + } + } + } + host.config.hd_mode = false; + host.world = Some(world.clone()); + let output = root.parent().unwrap().join(".reference/ui"); + std::fs::create_dir_all(&output).unwrap(); + let mut normal = Canvas::new(4); + host.scene(&mut normal).unwrap(); + image::save_buffer( + output.join("hud-full-original.png"), + &normal.rgba(&host.palette, false), + normal.image.width as u32, + normal.image.height as u32, + image::ColorType::Rgba8, + ) + .unwrap(); + world.tick = 1; + world.player.shield = 9; + world.player.super_shield = 0; + world.player.super_shield_reserves.clear(); + world + .player + .inventory + .remove(&ContentId::from("base:super_shield")); + host.hud_state.observe(&world, &host.active.registry); + assert_eq!(host.hud_state.damage_flashes, 2); + host.world = Some(world); + let mut critical = Canvas::new(4); + host.scene(&mut critical).unwrap(); + image::save_buffer( + output.join("hud-critical-original.png"), + &critical.rgba(&host.palette, false), + critical.image.width as u32, + critical.image.height as u32, + image::ColorType::Rgba8, + ) + .unwrap(); + assert!(!host.paths.save_path(0).exists()); + } + #[test] + fn failed_victory_save_can_retry_without_advancing_training_twice() { + let (_dir, mut host) = ui_fixture(); + let mission: crate::content::MissionDef = serde_json::from_value(serde_json::json!({ + "id":"base:sector_1_wave_4", "name":"Training graduation", "duration":100, + "legacy_map":42 + })) + .unwrap(); + host.active + .registry + .missions + .insert(mission.id.clone(), mission); + host.pilot.mission = "base:sector_1_wave_4".into(); + host.pilot.difficulty = 0; + host.pilot.credits = 6_000; + host.config.difficulty = 0; + let before = serde_json::to_value(&host.pilot).unwrap(); + let mut world = + World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap(); + world.player.credits = 12_345; + world.outcome = Outcome::Complete; + host.world = Some(world); + host.screen = Screen::Game; + std::fs::write(&host.paths.data, "blocked save directory").unwrap(); + assert!(host.update(Input::default(), 43_000_000).is_err()); + assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before); + assert_eq!(host.config.difficulty, 0); + assert_eq!(host.world.as_ref().unwrap().outcome, Outcome::Complete); + std::fs::remove_file(&host.paths.data).unwrap(); + host.update(Input::default(), 43_000_000).unwrap(); + let saved = + Pilot::load(&host.paths.save_path(host.slot), &host.active.registry, &[]).unwrap(); + assert_eq!(saved.mission.0, "base:sector_1_wave_1"); + assert_eq!(saved.difficulty, 1); + assert_eq!(saved.credits, 10_000); + assert_eq!(saved.inventory[&"base:energy".into()], 75); + assert!(saved.legacy_progress.as_ref().unwrap().training_completed); + assert_eq!( + saved.completed, + vec![ContentId::from("base:sector_1_wave_4")] + ); + assert_eq!(host.config.difficulty, 1); + assert!(host.screen == Screen::Results); + } + #[test] + fn committed_victory_is_not_reprocessed_when_only_settings_save_fails() { + let (_dir, mut host) = ui_fixture(); + let mut world = + World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap(); + world.player.credits = 1_234; + world.outcome = Outcome::Complete; + host.world = Some(world); + host.screen = Screen::Game; + std::fs::write(&host.paths.config, "blocked settings directory").unwrap(); + host.update(Input::default(), 43_000_000).unwrap(); + assert!(host.screen == Screen::Results); + assert!(host.message.contains("settings save failed")); + assert_eq!(host.pilot.credits, 1_234); + assert_eq!(host.pilot.completed.len(), 1); + let saved = + Pilot::load(&host.paths.save_path(host.slot), &host.active.registry, &[]).unwrap(); + assert_eq!(saved.credits, 1_234); + let before = serde_json::to_value(&host.pilot).unwrap(); + host.update(Input::default(), 43_000_000).unwrap(); + assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before); + } + #[test] + fn loading_a_pilot_restores_scripts_before_hangar_saves() { + let (_, mut pilot) = fixture(); + pilot.script_state.insert( + "weapon:behavior.rhai".into(), + serde_json::json!({"shots": 42}), + ); + pilot.script_rng.insert( + "weapon:behavior.rhai".into(), + crate::simulation::LegacyRng::new(731), + ); + let mut runtime = modding::ScriptRuntime::new(); + restore_scripts(&mut runtime, &pilot); + let mut resaved = pilot.clone(); + resaved.script_state = runtime.states.clone(); + resaved.script_rng = runtime.rng.clone(); + assert_eq!(resaved.script_state, pilot.script_state); + assert_eq!(resaved.script_rng["weapon:behavior.rhai"].seed, 731); + restore_scripts(&mut runtime, &Pilot::default()); + assert!(runtime.states.is_empty()); + assert!(runtime.rng.is_empty()); + } + fn fixture() -> (Registry, Pilot) { + let mut registry = Registry::default(); + let mut pilot = Pilot::default(); + pilot.inventory.clear(); + let mission: crate::content::MissionDef = serde_json::from_value(serde_json::json!({ + "id": pilot.mission, "name": "Save safety fixture", "duration": 100 + })) + .unwrap(); + registry.missions.insert(mission.id.clone(), mission); + (registry, pilot) + } + #[test] + fn applying_mods_checks_saved_pilot_instead_of_display_placeholder() { + let (registry, placeholder) = fixture(); + assert!(placeholder.validate(®istry, &[]).is_ok()); + let pin = modding::ModPin { + id: "example".into(), + version: "1.0.0".into(), + hash: "required".into(), + }; + let mut saved = placeholder.clone(); + saved.mods = vec![pin.clone()]; + saved.callsign = "SAVED PILOT".into(); + saved.credits = 4242; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pilot.json"); + saved.save(&path).unwrap(); + let before = std::fs::read(&path).unwrap(); + assert!(pilot_for_mods(&path, &placeholder, ®istry, &[]).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), before); + let restored = pilot_for_mods(&path, &placeholder, ®istry, &[pin]).unwrap(); + assert_eq!(restored.callsign, "SAVED PILOT"); + assert_eq!(restored.credits, 4242); + } + #[test] + fn corrupt_existing_slot_cannot_be_replaced_by_valid_placeholder() { + let (registry, placeholder) = fixture(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pilot.json"); + std::fs::write(&path, b"corrupt existing save").unwrap(); + assert!(pilot_for_mods(&path, &placeholder, ®istry, &[]).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"corrupt existing save"); + let unused = dir.path().join("unused.json"); + assert_eq!( + pilot_for_mods(&unused, &placeholder, ®istry, &[]) + .unwrap() + .callsign, + placeholder.callsign + ); + } + #[test] + fn full_hd_window_retains_centered_four_three_viewport() { + assert_eq!(viewport(1920, 1080, true), Rect::new(240, 0, 1440, 1080)); + assert_eq!(viewport(1920, 1080, false), Rect::new(0, 0, 1920, 1080)); + } + #[test] + fn palette_effects_do_not_consume_simulation_rng() { + let (registry, pilot) = fixture(); + let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); + world.tick = 7; + world.palette_blue = false; + let before = world.rng.seed; + let base = [[255, 128, 64]; 256]; + let first = mission_palette(&base, Some(&world)); + assert_eq!(first, mission_palette(&base, Some(&world))); + assert_eq!(world.rng.seed, before); + assert_eq!(first[254], [0; 3]); + world.palette_blue = true; + assert_eq!(mission_palette(&base, Some(&world))[254], base[254]); + } +} diff --git a/src/hd.rs b/RustRaptor/src/hd.rs similarity index 100% rename from src/hd.rs rename to RustRaptor/src/hd.rs diff --git a/src/legacy_tables.rs b/RustRaptor/src/legacy_tables.rs similarity index 100% rename from src/legacy_tables.rs rename to RustRaptor/src/legacy_tables.rs diff --git a/src/lib.rs b/RustRaptor/src/lib.rs similarity index 91% rename from src/lib.rs rename to RustRaptor/src/lib.rs index ad8b064f..e42ec899 100644 --- a/src/lib.rs +++ b/RustRaptor/src/lib.rs @@ -6,6 +6,7 @@ pub mod content; pub mod desktop; pub mod hd; mod legacy_tables; +pub mod mod_selection; pub mod modding; pub mod persistence; pub mod render; diff --git a/src/main.rs b/RustRaptor/src/main.rs similarity index 93% rename from src/main.rs rename to RustRaptor/src/main.rs index 5880d354..e624d7be 100644 --- a/src/main.rs +++ b/RustRaptor/src/main.rs @@ -36,9 +36,14 @@ fn run() -> Result<()> { println!("Wrote API v1 schemas to {path}"); return Ok(()); } - let data = arg("--data") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data")); + let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let data = arg("--data").map(PathBuf::from).unwrap_or_else(|| { + if package.join("data/INDEX").is_file() { + package.join("data") + } else { + package.join("../data") + } + }); let catalog = Catalog::open(data)?; let base = Registry::base(&catalog)?; if args.iter().any(|a| a == "--verify-assets") { @@ -60,9 +65,13 @@ fn run() -> Result<()> { paths.cache = root.join("cache"); paths.legacy = root.join("legacy"); } - let root = arg("--mods") - .map(PathBuf::from) - .unwrap_or_else(|| paths.mods()); + let root = arg("--mods").map(PathBuf::from).unwrap_or_else(|| { + if paths.mods().is_dir() { + paths.mods() + } else { + package.join("mods") + } + }); let discovery = modding::discover(&root); if let Some(ids) = arg("--validate-mods") { let enabled = ids @@ -197,7 +206,7 @@ fn run() -> Result<()> { } #[cfg(not(feature = "desktop"))] { - let _ = (catalog, base, paths, root); + let _ = (catalog, base, paths, root, benchmark); return Err(anyhow::anyhow!( "desktop support is disabled; use a headless command" )); diff --git a/RustRaptor/src/mod_selection.rs b/RustRaptor/src/mod_selection.rs new file mode 100644 index 00000000..ebcdf9ab --- /dev/null +++ b/RustRaptor/src/mod_selection.rs @@ -0,0 +1,146 @@ +//! Dependency-aware plans for the manager's pending selections. +//! Planning never changes active content, installed files or the caller's list. +use crate::{ + content::API_VERSION, + modding::{self, Discovery}, +}; +use anyhow::{Context, Result, ensure}; +use semver::{Version, VersionReq}; +use std::collections::BTreeSet; + +/// Check and select the requested package and its installed dependencies. +/// Existing independent priority is preserved; dependencies precede dependents. +pub fn plan_enable(discovery: &Discovery, enabled: &[String], id: &str) -> Result> { + let mut selected = enabled.to_vec(); + if !selected.iter().any(|value| value == id) { + selected.push(id.into()); + } + let roots = selected.clone(); + let mut visiting = BTreeSet::new(); + let mut done = BTreeSet::new(); + + fn visit( + discovery: &Discovery, + id: &str, + selected: &mut Vec, + visiting: &mut BTreeSet, + done: &mut BTreeSet, + ) -> Result<()> { + if done.contains(id) { + return Ok(()); + } + ensure!( + visiting.len() < 256, + "dependency chain exceeds 256 packages at {id}" + ); + ensure!(visiting.insert(id.into()), "cyclic mod dependency at {id}"); + let package = discovery + .mods + .get(id) + .with_context(|| format!("mod {id} is not installed; install it, then rescan Mods"))?; + ensure!( + package.manifest.api_version == API_VERSION, + "{id}: requires API {}, game provides {API_VERSION}", + package.manifest.api_version + ); + for (dependency, requirement) in &package.manifest.dependencies { + let installed = discovery.mods.get(dependency).with_context(|| { + format!("{id} requires {dependency} {requirement}; install it, then rescan Mods") + })?; + let version = Version::parse(&installed.manifest.version) + .with_context(|| format!("{dependency}: invalid installed version"))?; + ensure!( + VersionReq::parse(requirement)?.matches(&version), + "{id} requires {dependency} {requirement}, installed {version}" + ); + if !selected.contains(dependency) { + selected.push(dependency.clone()); + } + visit(discovery, dependency, selected, visiting, done)?; + } + visiting.remove(id); + done.insert(id.into()); + Ok(()) + } + + for root in roots { + visit(discovery, &root, &mut selected, &mut visiting, &mut done)?; + } + // Reuse activation's API/conflict/version/order checks. Nothing is committed + // if any selected package or newly discovered dependency fails validation. + modding::order(discovery, &selected) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DisablePlan { + /// Pending selection after accepting this plan. + pub enabled: Vec, + /// Requested package, dependents and unused dependencies, in current order. + pub removed: Vec, +} + +/// Plan a removal with dependent packages and their now-unused dependencies. +/// Dependencies needed by another remaining package stay selected. The manager +/// confirms plans affecting multiple packages; a single package toggles directly. +pub fn plan_disable(discovery: &Discovery, enabled: &[String], id: &str) -> DisablePlan { + let mut removed = BTreeSet::new(); + if enabled.iter().any(|value| value == id) { + removed.insert(id.to_string()); + } + loop { + let before = removed.len(); + for candidate in enabled { + if discovery.mods.get(candidate).is_some_and(|package| { + package + .manifest + .dependencies + .keys() + .any(|dependency| removed.contains(dependency)) + }) { + removed.insert(candidate.clone()); + } + } + if removed.len() == before { + break; + } + } + fn dependencies(discovery: &Discovery, roots: &BTreeSet) -> BTreeSet { + let mut found = BTreeSet::new(); + let mut queue: Vec<_> = roots.iter().cloned().collect(); + while let Some(id) = queue.pop() { + if let Some(package) = discovery.mods.get(&id) { + for dependency in package.manifest.dependencies.keys() { + if found.insert(dependency.clone()) { + queue.push(dependency.clone()); + } + } + } + } + found + } + let candidates = dependencies(discovery, &removed); + let remaining_roots = enabled + .iter() + .filter(|id| !removed.contains(*id) && !candidates.contains(*id)) + .cloned() + .collect(); + let still_needed = dependencies(discovery, &remaining_roots); + let selected: BTreeSet<_> = enabled.iter().cloned().collect(); + removed.extend( + candidates + .into_iter() + .filter(|id| selected.contains(id) && !still_needed.contains(id)), + ); + DisablePlan { + enabled: enabled + .iter() + .filter(|id| !removed.contains(*id)) + .cloned() + .collect(), + removed: enabled + .iter() + .filter(|id| removed.contains(*id)) + .cloned() + .collect(), + } +} diff --git a/src/modding.rs b/RustRaptor/src/modding.rs similarity index 99% rename from src/modding.rs rename to RustRaptor/src/modding.rs index 600c6e49..9a82a567 100644 --- a/src/modding.rs +++ b/RustRaptor/src/modding.rs @@ -35,7 +35,7 @@ pub struct Manifest { pub api_version: u32, #[serde(default)] pub description: String, - /// Required, explicitly enabled mod IDs mapped to semantic version requirements. + /// Required mod IDs mapped to semantic version requirements. #[serde(default)] pub dependencies: BTreeMap, /// IDs which cannot be enabled at the same time as this package. diff --git a/src/persistence.rs b/RustRaptor/src/persistence.rs similarity index 62% rename from src/persistence.rs rename to RustRaptor/src/persistence.rs index 60c7f1fc..dc3bc974 100644 --- a/src/persistence.rs +++ b/RustRaptor/src/persistence.rs @@ -81,9 +81,9 @@ impl Default for Config { ("left", "Left"), ("right", "Right"), ("fire", "Left Ctrl"), - ("special", "Left Alt"), - ("cycle", "Space"), - ("bomb", "Right Shift"), + ("special", "Z"), + ("cycle", "Left Alt"), + ("bomb", "Space"), ] .into_iter() .map(|(k, v)| (k.into(), v.into())) @@ -96,10 +96,11 @@ impl Config { pub fn load(paths: &Paths) -> Result { let path = paths.config.join("config.toml"); let mut c = if path.exists() { - toml::from_str(&fs::read_to_string(path)?)? + toml::from_str(&fs::read_to_string(&path)?)? } else { Self::import_ini(&paths.legacy.join("SETUP.INI"))? }; + c.migrate_default_bindings(path.exists()); ensure!( matches!(c.scale, 1 | 2 | 3 | 4) && c.width >= 320 @@ -125,6 +126,15 @@ impl Config { if !path.exists() { return Ok(config); } + // Missing INI keys inherit the original engine's keyboard defaults. + // After import, migrate only an unchanged legacy action trio. + for (action, key) in [ + ("special", "Left Alt"), + ("cycle", "Space"), + ("bomb", "Right Shift"), + ] { + config.bindings.insert(action.into(), key.into()); + } let mut section = String::new(); for line in fs::read_to_string(path)?.lines() { let line = line.trim(); @@ -179,11 +189,36 @@ impl Config { _ => {} } } + config.migrate_default_bindings(false); Ok(config) } + fn migrate_default_bindings(&mut self, rust_config: bool) { + let matches = + |action: &str, key: &str| self.bindings.get(action).is_some_and(|value| value == key); + if matches("cycle", "Space") + && matches("special", "Left Alt") + && (matches("bomb", "Right Shift") || rust_config && matches("bomb", "B")) + { + self.bindings.insert("cycle".into(), "Left Alt".into()); + self.bindings.insert("bomb".into(), "Space".into()); + // Keep unrelated custom keys; choose a free item-use key if the + // pilot already assigned Z to movement or firing. + let special = ["Z", "X", "C", "V", "E", "F"] + .into_iter() + .find(|key| { + !self + .bindings + .iter() + .any(|(action, bound)| action != "special" && bound.as_str() == *key) + }) + .unwrap_or("Z"); + self.bindings.insert("special".into(), special.into()); + } + } } fn legacy_scancode(n: i32) -> Option<&'static str> { Some(match n { + 0 | 255 => "unbound", 72 => "Up", 80 => "Down", 75 => "Left", @@ -191,17 +226,210 @@ fn legacy_scancode(n: i32) -> Option<&'static str> { 29 => "Left Ctrl", 56 => "Left Alt", 57 => "Space", + 42 => "Left Shift", 54 => "Right Shift", + 1 => "Escape", + 2 => "1", + 3 => "2", + 4 => "3", + 5 => "4", + 6 => "5", + 7 => "6", + 8 => "7", + 9 => "8", + 10 => "9", + 11 => "0", + 12 => "-", + 14 => "Backspace", + 15 => "Tab", 17 => "W", 30 => "A", 31 => "S", 32 => "D", 16 => "Q", 18 => "E", + 19 => "R", + 20 => "T", + 21 => "Y", + 22 => "U", + 23 => "I", + 24 => "O", + 25 => "P", + 33 => "F", + 34 => "G", + 35 => "H", + 36 => "J", + 37 => "K", + 38 => "L", + 43 => "\\", + 44 => "Z", + 45 => "X", + 46 => "C", + 47 => "V", + 48 => "B", + 49 => "N", + 50 => "M", 28 => "Return", + 58 => "CapsLock", + 59 => "F1", + 60 => "F2", + 61 => "F3", + 62 => "F4", + 63 => "F5", + 64 => "F6", + 65 => "F7", + 66 => "F8", + 67 => "F9", + 68 => "F10", + 87 => "F11", + 88 => "F12", + 70 => "ScrollLock", + 71 | 0xe047 => "Home", + 79 | 0xe04f => "End", + 73 | 0xe049 => "PageUp", + 81 | 0xe051 => "PageDown", + 82 | 0xe052 => "Insert", + 83 | 0xe053 => "Delete", + 0xe038 => "Right Alt", + 0xe010 | 0xe01d => "Right Ctrl", + 0xe01c => "Keypad Enter", + 0xe048 => "Up", + 0xe050 => "Down", + 0xe04b => "Left", + 0xe04d => "Right", _ => return None, }) } +#[cfg(test)] +mod binding_tests { + use super::*; + fn paths(root: &Path) -> Paths { + Paths { + data: root.join("data"), + config: root.join("config"), + cache: root.join("cache"), + legacy: root.join("legacy"), + } + } + #[test] + fn default_controls_use_space_bombs_and_alt_cycling_with_a_separate_item_key() { + let config = Config::default(); + assert_eq!(config.bindings["bomb"], "Space"); + assert_eq!(config.bindings["cycle"], "Left Alt"); + assert_eq!(config.bindings["special"], "Z"); + assert_eq!(config.bindings["fire"], "Left Ctrl"); + assert_eq!( + config + .bindings + .values() + .collect::>() + .len(), + config.bindings.len() + ); + } + #[test] + fn loading_known_earlier_default_trios_preserves_other_custom_settings() { + for bomb in ["Right Shift", "B"] { + let dir = tempfile::tempdir().unwrap(); + let paths = paths(dir.path()); + let mut old = Config::default(); + old.bindings.insert("cycle".into(), "Space".into()); + old.bindings.insert("special".into(), "Left Alt".into()); + old.bindings.insert("bomb".into(), bomb.into()); + old.bindings.insert("up".into(), "W".into()); + old.bindings.insert("fire".into(), "Right Ctrl".into()); + old.scale = 2; + old.music_volume = 31; + old.save(&paths).unwrap(); + let source = fs::read(paths.config.join("config.toml")).unwrap(); + let migrated = Config::load(&paths).unwrap(); + assert_eq!(migrated.bindings["cycle"], "Left Alt"); + assert_eq!(migrated.bindings["bomb"], "Space"); + assert_eq!(migrated.bindings["special"], "Z"); + assert_eq!(migrated.bindings["up"], "W"); + assert_eq!(migrated.bindings["fire"], "Right Ctrl"); + assert_eq!((migrated.scale, migrated.music_volume), (2, 31)); + assert_eq!(fs::read(paths.config.join("config.toml")).unwrap(), source); + migrated.save(&paths).unwrap(); + assert_eq!(Config::load(&paths).unwrap().bindings, migrated.bindings); + } + } + #[test] + fn customized_action_trios_are_not_migrated() { + for (action, key) in [("cycle", "Q"), ("special", "E"), ("bomb", "C")] { + let dir = tempfile::tempdir().unwrap(); + let paths = paths(dir.path()); + let mut config = Config::default(); + for (a, k) in [ + ("cycle", "Space"), + ("special", "Left Alt"), + ("bomb", "Right Shift"), + ] { + config.bindings.insert(a.into(), k.into()); + } + config.bindings.insert(action.into(), key.into()); + config.save(&paths).unwrap(); + assert_eq!(Config::load(&paths).unwrap().bindings, config.bindings); + } + } + #[test] + fn migration_avoids_a_custom_binding_on_the_new_item_key() { + let mut config = Config::default(); + for (a, k) in [ + ("cycle", "Space"), + ("special", "Left Alt"), + ("bomb", "B"), + ("fire", "Z"), + ] { + config.bindings.insert(a.into(), k.into()); + } + config.migrate_default_bindings(true); + assert_eq!(config.bindings["fire"], "Z"); + assert_eq!(config.bindings["special"], "X"); + } + #[test] + fn legacy_custom_shift_and_letter_controls_import_without_default_migration() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("SETUP.INI"); + let source = b"[Keyboard]\nChangeSp=42\nFireSp=44\nMegaFire=57\nMoveUp=17\n"; + fs::write(&path, source).unwrap(); + let config = Config::import_ini(&path).unwrap(); + assert_eq!(config.bindings["cycle"], "Left Shift"); + assert_eq!(config.bindings["special"], "Z"); + assert_eq!(config.bindings["bomb"], "Space"); + assert_eq!(config.bindings["up"], "W"); + assert_eq!(fs::read(&path).unwrap(), source); + } + #[test] + fn custom_legacy_b_bomb_and_disabled_special_are_preserved() { + let dir = tempfile::tempdir().unwrap(); + let paths = paths(dir.path()); + fs::create_dir_all(&paths.legacy).unwrap(); + let path = paths.legacy.join("SETUP.INI"); + fs::write(&path, b"[Keyboard]\nMegaFire=48\n").unwrap(); + let config = Config::load(&paths).unwrap(); + assert_eq!(config.bindings["bomb"], "B"); + assert_eq!(config.bindings["cycle"], "Space"); + assert_eq!(config.bindings["special"], "Left Alt"); + fs::write(&path, b"[Keyboard]\nFireSp=0\n").unwrap(); + assert_eq!(Config::load(&paths).unwrap().bindings["special"], "unbound"); + } + #[cfg(feature = "desktop")] + #[test] + fn imported_legacy_keys_use_valid_sdl_names() { + for code in (1..=88).chain([ + 0xe038, 0xe010, 0xe01d, 0xe01c, 0xe048, 0xe050, 0xe04b, 0xe04d, 0xe047, 0xe04f, 0xe049, + 0xe051, 0xe052, 0xe053, + ]) { + if let Some(name) = legacy_scancode(code) { + assert!( + sdl2::keyboard::Scancode::from_name(name).is_some(), + "invalid SDL key {name}" + ); + } + } + } +} pub fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { let parent = path.parent().context("output has no parent")?; fs::create_dir_all(parent)?; @@ -252,6 +480,12 @@ pub struct Pilot { pub credits: u32, pub difficulty: u32, pub inventory: BTreeMap, + /// Unequipped original modules in inventory order. The equipped module's + /// charge remains inventory[base:super_shield] for version-one compatibility. + #[serde(default)] + pub super_shield_reserves: Vec, + #[serde(default)] + pub shield_recharge_ticks: u32, pub selected: Option, pub completed: Vec, pub mission: ContentId, @@ -273,6 +507,8 @@ impl Default for Pilot { inventory: [("base:forward_guns".into(), 1), ("base:energy".into(), 100)] .into_iter() .collect(), + super_shield_reserves: vec![], + shield_recharge_ticks: 0, selected: None, completed: vec![], mission: "base:sector_1_wave_1".into(), @@ -319,6 +555,8 @@ impl Pilot { .into_iter() .collect(); self.selected = None; + self.super_shield_reserves.clear(); + self.shield_recharge_ticks = 0; self.credits = 10000; progress.training_completed = true; for difficulty in &mut progress.difficulties { @@ -399,9 +637,23 @@ impl Pilot { let p = self; ensure!(p.version == 1, "unsupported save version {}", p.version); ensure!( - p.difficulty <= 3 && p.inventory.len() <= 20, + p.difficulty <= 3 && p.inventory_slots() <= 20 && p.shield_recharge_ticks <= 96, "invalid pilot difficulty/inventory" ); + ensure!( + p.super_shield_reserves.is_empty() + || p.inventory + .contains_key(&ContentId::from("base:super_shield")), + "reserve shields require an equipped module" + ); + if let Some(item) = registry.items.get(&ContentId::from("base:super_shield")) { + ensure!( + p.super_shield_reserves + .iter() + .all(|charge| *charge <= item.max_count), + "saved reserve shield charge exceeds capacity" + ); + } let mut cursor = 0; for required in &p.mods { let position = pins[cursor..].iter().position(|pin| pin.id == required.id) @@ -460,10 +712,24 @@ impl Pilot { pub fn buy(&mut self, id: &ContentId, registry: &Registry) -> Result<()> { let item = registry.items.get(id).context("unknown shop item")?; ensure!(self.credits >= item.price, "not enough credits"); + if id.0 == "base:super_shield" { + ensure!( + self.item_count(id) < 5, + "ship already carries five super-shield modules" + ); + ensure!(self.inventory_slots() < 20, "ship inventory is full"); + self.credits -= item.price; + if self.inventory.contains_key(id) { + self.super_shield_reserves.push(item.initial_count); + } else { + self.inventory.insert(id.clone(), item.initial_count); + } + return Ok(()); + } let count = self.inventory.get(id).copied().unwrap_or(0); ensure!(count < item.max_count, "item capacity reached"); ensure!( - count > 0 || self.inventory.len() < 20, + self.inventory.contains_key(id) || self.inventory_slots() < 20, "ship inventory is full" ); self.credits -= item.price; @@ -476,13 +742,38 @@ impl Pilot { pub fn sell(&mut self, id: &ContentId, registry: &Registry) -> Result<()> { let item = registry.items.get(id).context("unknown shop item")?; ensure!( - id.0 != "base:forward_guns" && id.0 != "base:energy", + id.0 != "base:forward_guns", "essential ship equipment cannot be sold" ); + if id.0 == "base:super_shield" { + let charge = self.inventory.get(id).context("item is not owned")?; + ensure!( + *charge >= item.initial_count, + "only a fully charged equipped super-shield module can be sold" + ); + self.credits = self.credits.saturating_add(item.price / 2); + if self.super_shield_reserves.is_empty() { + self.inventory.remove(id); + } else { + self.inventory + .insert(id.clone(), self.super_shield_reserves.remove(0)); + } + return Ok(()); + } + if id.0 == "base:energy" { + let count = self.inventory.get_mut(id).context("item is not owned")?; + ensure!( + *count > item.initial_count, + "retain at least some normal shield energy" + ); + *count -= item.initial_count; + self.credits = self.credits.saturating_add(item.price / 2); + return Ok(()); + } let count = self.inventory.get_mut(id).context("item is not owned")?; ensure!(*count > 0, "item is not owned"); *count -= 1; - self.credits = self.credits.saturating_add(item.price / 2); + self.credits = self.credits.saturating_add(item.resale_price()); if *count == 0 { self.inventory.remove(id); if self.selected.as_ref() == Some(id) { @@ -491,6 +782,17 @@ impl Pilot { } Ok(()) } + /// Actual owned quantity, distinct from original shield charge units. + pub fn item_count(&self, id: &ContentId) -> u32 { + if id.0 == "base:super_shield" { + self.inventory.contains_key(id) as u32 + self.super_shield_reserves.len() as u32 + } else { + self.inventory.get(id).copied().unwrap_or(0) + } + } + pub fn inventory_slots(&self) -> usize { + self.inventory.len() + self.super_shield_reserves.len() + } pub fn import_legacy(path: &Path, registry: &Registry) -> Result { let bytes = fs::read(path)?; ensure!( @@ -546,6 +848,8 @@ impl Pilot { ..Self::default() }; let special = val(40)?; + let mut shields = vec![]; + let mut equipped = None; for r in body.chunks_exact(24).take(count as usize) { let count = crate::assets::i32_at(r, 8)?; let typ = crate::assets::i32_at(r, 12)?; @@ -555,11 +859,32 @@ impl Pilot { .values() .find(|i| i.legacy == Some(typ as u32)) .context("unknown legacy item")?; - p.inventory.insert(item.id.clone(), count as u32); + if typ == 15 { + let inuse = crate::assets::i32_at(r, 20)?; + ensure!( + (0..=1).contains(&inuse), + "invalid legacy shield equipment flag" + ); + if inuse != 0 { + ensure!( + equipped.is_none(), + "multiple equipped legacy shield modules" + ); + equipped = Some(shields.len()); + } + shields.push(count as u32); + } else { + p.inventory.insert(item.id.clone(), count as u32); + } if typ == special { p.selected = Some(item.id.clone()); } } + if !shields.is_empty() { + let active = shields.remove(equipped.unwrap_or(0)); + p.inventory.insert("base:super_shield".into(), active); + p.super_shield_reserves = shields; + } for s in 0..3 { let w = val(48 + s * 4)?; for i in 1..=w { diff --git a/src/render.rs b/RustRaptor/src/render.rs similarity index 98% rename from src/render.rs rename to RustRaptor/src/render.rs index 2fa400b7..946c6c91 100644 --- a/src/render.rs +++ b/RustRaptor/src/render.rs @@ -468,6 +468,12 @@ impl BitmapFont { } } } + /// Logical pixel width, including the same one-pixel glyph spacing as `text`. + pub fn text_width(&self, text: &str) -> usize { + text.bytes() + .map(|byte| self.widths[byte as usize] + 1) + .sum() + } pub fn height(&self) -> usize { self.height } diff --git a/src/simulation.rs b/RustRaptor/src/simulation.rs similarity index 95% rename from src/simulation.rs rename to RustRaptor/src/simulation.rs index a13932c8..eebc3f3a 100644 --- a/src/simulation.rs +++ b/RustRaptor/src/simulation.rs @@ -145,6 +145,8 @@ pub struct Player { pub bank: i32, pub shield: i32, pub super_shield: i32, + #[serde(default)] + pub super_shield_reserves: Vec, pub credits: u32, pub inventory: BTreeMap, pub selected: Option, @@ -431,6 +433,8 @@ pub struct World { pub player_flash: bool, #[serde(default = "initial_shield_latch")] pub previous_shield: i32, + #[serde(default)] + pub shield_recharge_ticks: u32, #[serde(skip)] pub events: Vec, } @@ -471,6 +475,7 @@ impl World { .copied() .unwrap_or(0) .min(100) as i32, + super_shield_reserves: pilot.super_shield_reserves.clone(), credits: pilot.credits, inventory: pilot.inventory.clone(), selected: pilot.selected.clone(), @@ -507,6 +512,7 @@ impl World { special_held: false, player_flash: false, previous_shield: initial_shield_latch(), + shield_recharge_ticks: pilot.shield_recharge_ticks, events: vec![], }; out.event( @@ -520,6 +526,8 @@ impl World { let mut p = prior.clone(); p.credits = self.player.credits; p.inventory = self.player.inventory.clone(); + p.super_shield_reserves = self.player.super_shield_reserves.clone(); + p.shield_recharge_ticks = self.shield_recharge_ticks; p.inventory .insert("base:energy".into(), self.player.shield.max(1) as u32); if p.inventory @@ -725,6 +733,7 @@ impl World { } else { self.player.bank += (3 - self.player.bank).signum(); } + let mut equipment_used = false; if input.fire { let mut weapons: Vec<_> = self .player @@ -761,6 +770,7 @@ impl World { weapons.retain(|id| id != selected); weapons.push(selected.clone()); } + equipment_used |= !weapons.is_empty(); for id in weapons { self.fire(&id, registry)?; } @@ -768,6 +778,7 @@ impl World { if input.special { if let Some(id) = self.player.selected.clone() { if let Some(item) = registry.items.get(&id) { + equipment_used |= self.player.inventory.get(&id).is_some_and(|n| *n > 0); if let Some(weapon) = item.weapon.as_ref() { self.fire(weapon, registry)?; } else if !self.special_held @@ -800,6 +811,7 @@ impl World { if input.bomb && !self.bomb_held { let id = ContentId::from("base:mega_bomb"); if self.player.inventory.get(&id).copied().unwrap_or(0) > 0 { + equipment_used = true; if let Some(weapon) = registry.items.get(&id).and_then(|i| i.weapon.clone()) { let before = self.projectiles.len(); self.fire(&weapon, registry)?; @@ -1232,9 +1244,22 @@ impl World { }; if collision { let item = ®istry.items[&pickup.item]; - if item.credits == 0 + if pickup.item.0 == "base:super_shield" { + if inventory_slots(&self.player) < 20 { + if self.player.inventory.contains_key(&pickup.item) { + self.player.super_shield_reserves.push(item.initial_count); + } else { + self.player.super_shield = item.initial_count as i32; + self.player + .inventory + .insert(pickup.item.clone(), item.initial_count); + } + } + } else if pickup.item.0 == "base:energy" && item.legacy == Some(16) { + add_energy(&mut self.player, item.initial_count as i32); + } else if item.credits == 0 && (self.player.inventory.contains_key(&pickup.item) - || self.player.inventory.len() < 20) + || inventory_slots(&self.player) < 20) { let amount = self .player @@ -1248,9 +1273,9 @@ impl World { self.player.selected = Some(pickup.item.clone()); } } - if item.legacy == Some(15) { - self.player.super_shield = 100; - } else { + if pickup.item.0 != "base:super_shield" + && !(pickup.item.0 == "base:energy" && item.legacy == Some(16)) + { self.player.shield = (self.player.shield + item.shield).min(100); } self.player.credits = self.player.credits.saturating_add(item.credits); @@ -1377,6 +1402,24 @@ impl World { } } self.effects.retain(|e| e.age <= e.kind.artwork().1); + if self.difficulty < 3 { + if equipment_used { + self.shield_recharge_ticks = 0; + } else { + self.shield_recharge_ticks += 1; + if self.shield_recharge_ticks > 96 { + if self.end_timer.is_none() + && registry + .items + .get(&ContentId::from("base:energy")) + .is_some_and(|item| item.legacy == Some(16)) + { + add_energy(&mut self.player, 1); + } + self.shield_recharge_ticks = 0; + } + } + } self.tick += 1; if self.legacy_transition { if mission.auto_complete @@ -2482,20 +2525,38 @@ impl World { let id = ContentId::new(item)?; let def = registry.items.get(&id).context("unknown item")?; ensure!( - self.player.inventory.contains_key(&id) || self.player.inventory.len() < 20, + self.player.inventory.contains_key(&id) || inventory_slots(&self.player) < 20, "inventory capacity exceeded" ); - let amount = self.player.inventory.entry(id).or_default(); + let amount = self.player.inventory.entry(id.clone()).or_default(); *amount = amount.saturating_add(*count).min(def.max_count); + if id.0 == "base:super_shield" { + self.player.super_shield = *amount as i32; + } + if id.0 == "base:energy" { + self.player.shield = *amount as i32; + } } Command::ConsumeItem { item, count } => { + let id = ContentId::new(item)?; let amount = self .player .inventory - .get_mut(&ContentId::new(item)?) + .get_mut(&id) .context("item is not owned")?; ensure!(*amount >= *count, "insufficient item quantity"); *amount -= count; + if id.0 == "base:super_shield" { + self.player.super_shield = *amount as i32; + } + if id.0 == "base:energy" { + self.player.shield = *amount as i32; + } else if id.0 != "base:super_shield" && *amount == 0 { + self.player.inventory.remove(&id); + if self.player.selected.as_ref() == Some(&id) { + self.player.selected = next_special(&self.player, registry); + } + } } Command::Credits { amount } => { self.player.credits = self.player.credits.saturating_add_signed(*amount); @@ -2751,14 +2812,44 @@ fn damage_player( p.super_shield -= damage; play_legacy_sound(rng, sounds, events, "FX_SHIT"); if p.super_shield < 0 { - p.super_shield = 0; - p.inventory.remove(&super_id); + if p.super_shield_reserves.is_empty() { + p.super_shield = 0; + p.inventory.remove(&super_id); + } else { + p.super_shield = p.super_shield_reserves.remove(0) as i32; + p.inventory.insert(super_id.clone(), p.super_shield as u32); + } + } else { + p.inventory.insert(super_id, p.super_shield as u32); } } else { p.shield = (p.shield - damage).max(0); + if let Some(charge) = p.inventory.get_mut(&ContentId::from("base:energy")) { + *charge = p.shield as u32; + } play_legacy_sound(rng, sounds, events, "FX_HIT"); } } +fn add_energy(p: &mut Player, amount: i32) { + if !p.inventory.contains_key(&ContentId::from("base:energy")) { + return; + } + if p.shield < 100 { + p.shield = (p.shield + amount).clamp(0, 100); + p.inventory.insert("base:energy".into(), p.shield as u32); + } else if p + .inventory + .contains_key(&ContentId::from("base:super_shield")) + && p.super_shield > 0 + { + p.super_shield = (p.super_shield + (amount >> 2)).min(100); + p.inventory + .insert("base:super_shield".into(), p.super_shield as u32); + } +} +fn inventory_slots(p: &Player) -> usize { + p.inventory.len() + p.super_shield_reserves.len() +} fn target_matches(t: &Target, ground: bool) -> bool { match t { Target::All => true, diff --git a/RustRaptor/src/sound.rs b/RustRaptor/src/sound.rs new file mode 100644 index 00000000..0b62a6ba --- /dev/null +++ b/RustRaptor/src/sound.rs @@ -0,0 +1,241 @@ +//! Checked package-local PCM WAV loading, independent of SDL2. +//! RIFF chunks are little-endian and padded to a two-byte boundary. +use crate::{ + assets::Catalog, + audio::{DigitalSample, Mixer}, + content::{ContentId, Registry}, + simulation::SoundEvent, +}; +use anyhow::{Context, Result, ensure}; +use std::{collections::BTreeMap, path::Path}; + +/// Original FX IDs name empty catalog markers. Their digital device variant is +/// four entries later, matching SND_Setup's SND_DIGITAL offset. +const DIGITAL_VARIANT: u32 = 4; +const MOD_CACHE_LIMIT: usize = 64 * 1024 * 1024; + +#[derive(Default)] +pub struct SoundBank { + original: BTreeMap, + mods: BTreeMap, +} +impl SoundBank { + pub fn preload_originals(&mut self, catalog: &Catalog) -> Result<()> { + for sound in crate::legacy_tables::sounds() { + self.original(catalog, sound.asset)?; + } + Ok(()) + } + pub fn original(&mut self, catalog: &Catalog, marker: u32) -> Result { + if !self.original.contains_key(&marker) { + let is_marker = crate::legacy_tables::sounds() + .iter() + .any(|s| s.asset == marker); + let asset = if is_marker { + marker + .checked_add(DIGITAL_VARIANT) + .context("sound variant overflow")? + } else { + marker + }; + let sample = DigitalSample::decode(&catalog.read(asset)?) + .with_context(|| format!("original sound {marker:#x}, digital asset {asset:#x}"))?; + self.original.insert(marker, sample); + } + Ok(self.original[&marker].clone()) + } + pub fn queue_original( + &mut self, + catalog: &Catalog, + mixer: &mut Mixer, + event: &SoundEvent, + ) -> Result<()> { + let sample = self.original(catalog, event.asset)?; + mixer.sample(sample, event.pan, event.pitch, event.volume, event.priority); + Ok(()) + } + pub fn mod_sample(&mut self, registry: &Registry, id: &ContentId) -> Result { + let definition = registry + .sounds + .get(id) + .context("missing mod sound definition")?; + if !self.mods.contains_key(id) { + let owner = registry.owners.get(id).context("missing sound owner")?; + let root = registry + .asset_roots + .get(owner) + .context("missing sound asset root")?; + let path = crate::assets::contained(root, Path::new(&definition.file))?; + let sample = + decode(&std::fs::read(path)?).with_context(|| format!("mod sound {id}"))?; + if self.mods.values().map(|s| s.data.len()).sum::() + sample.data.len() + > MOD_CACHE_LIMIT + { + self.mods.clear(); + } + self.mods.insert(id.clone(), sample); + } + Ok(self.mods[id].clone()) + } + pub fn queue_mod( + &mut self, + registry: &Registry, + mixer: &mut Mixer, + id: &ContentId, + ) -> Result<()> { + let sample = self.mod_sample(registry, id)?; + let definition = ®istry.sounds[id]; + mixer.sample( + sample, + definition.pan, + definition.pitch, + definition.volume, + definition.priority as i32, + ); + Ok(()) + } + pub fn clear_mods(&mut self) { + self.mods.clear(); + } + pub fn original_count(&self) -> usize { + self.original.len() + } +} + +/// UI cues use the native metadata without consuming gameplay RNG. +pub fn ui_event(name: &str) -> Result { + let sound = crate::legacy_tables::sounds() + .into_iter() + .find(|s| s.name == name) + .context("unknown original UI sound")?; + Ok(SoundEvent { + asset: sound.asset, + pan: 127, + pitch: sound.pitch, + volume: sound.volume, + priority: sound.priority, + }) +} + +/// Write a captured production stereo callback as a standard PCM16 WAV. +pub fn encode_capture(capture: &crate::audio::AudioCapture) -> Result> { + ensure!( + capture.samples.len() % 2 == 0, + "partial captured stereo frame" + ); + let size = capture + .samples + .len() + .checked_mul(2) + .and_then(|n| u32::try_from(n).ok()) + .context("audio capture too large")?; + let mut bytes = Vec::with_capacity(size as usize + 44); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&(36 + size).to_le_bytes()); + bytes.extend_from_slice(b"WAVEfmt "); + bytes.extend_from_slice(&16_u32.to_le_bytes()); + bytes.extend_from_slice(&1_u16.to_le_bytes()); + bytes.extend_from_slice(&2_u16.to_le_bytes()); + bytes.extend_from_slice(&capture.rate.to_le_bytes()); + bytes.extend_from_slice(&(capture.rate * 4).to_le_bytes()); + bytes.extend_from_slice(&4_u16.to_le_bytes()); + bytes.extend_from_slice(&16_u16.to_le_bytes()); + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&size.to_le_bytes()); + for sample in &capture.samples { + bytes.extend_from_slice(&sample.to_le_bytes()); + } + Ok(bytes) +} + +pub fn decode(bytes: &[u8]) -> Result { + ensure!(bytes.len() <= 8 * 1024 * 1024, "sound exceeds 8 MiB"); + if !bytes.starts_with(b"RIFF") { + return DigitalSample::decode(bytes); + } + let u32_at = |offset| -> Result { + Ok(u32::from_le_bytes( + bytes + .get(offset..offset + 4) + .context("truncated WAV field")? + .try_into()?, + )) + }; + ensure!( + bytes.get(8..12) == Some(b"WAVE") && u32_at(4)? as usize == bytes.len().saturating_sub(8), + "invalid RIFF/WAVE length" + ); + let mut format = None; + let mut audio = None; + let mut offset = 12; + while offset < bytes.len() { + let tag = bytes + .get(offset..offset + 4) + .context("truncated WAV chunk")?; + let length = u32_at(offset + 4)? as usize; + let start = offset + 8; + let end = start + .checked_add(length) + .context("WAV chunk size overflow")?; + let chunk = bytes + .get(start..end) + .context("truncated WAV chunk payload")?; + match tag { + b"fmt " => { + ensure!( + format.is_none() && chunk.len() >= 16, + "duplicate/truncated WAV format" + ); + let u16_at = |n| u16::from_le_bytes([chunk[n], chunk[n + 1]]); + let rate = u32::from_le_bytes(chunk[4..8].try_into()?); + let channels = u16_at(2) as usize; + let bits = u16_at(14) as usize; + ensure!( + u16_at(0) == 1 + && matches!(channels, 1 | 2) + && matches!(bits, 8 | 16) + && (8000..=48000).contains(&rate), + "sound must be 8/16-bit PCM WAV, mono/stereo, 8–48 kHz" + ); + let align = channels * bits / 8; + ensure!( + u16_at(12) as usize == align + && u32::from_le_bytes(chunk[8..12].try_into()?) == rate * align as u32, + "invalid WAV block/byte rate" + ); + format = Some((rate, channels, bits, align)); + } + b"data" => { + ensure!(audio.is_none(), "duplicate WAV data"); + audio = Some(chunk); + } + _ => {} + } + offset = end + .checked_add(length & 1) + .context("WAV padding overflow")?; + ensure!(offset <= bytes.len(), "truncated WAV padding"); + } + let (rate, channels, bits, align) = format.context("WAV has no format")?; + let audio = audio.context("WAV has no audio data")?; + ensure!( + !audio.is_empty() && audio.len() % align == 0 && audio.len() / align <= rate as usize * 30, + "invalid WAV sample count (maximum 30 seconds)" + ); + let data = audio + .chunks_exact(align) + .map(|frame| { + let total: i32 = (0..channels) + .map(|channel| { + if bits == 8 { + (frame[channel] as i32 - 128) * 256 + } else { + i16::from_le_bytes([frame[channel * 2], frame[channel * 2 + 1]]) as i32 + } + }) + .sum(); + ((total / channels as i32) / 256 + 128).clamp(0, 255) as u8 + }) + .collect(); + Ok(DigitalSample { rate, data }) +} diff --git a/tests/audio.rs b/RustRaptor/tests/audio.rs similarity index 79% rename from tests/audio.rs rename to RustRaptor/tests/audio.rs index 1724c1ae..952443e6 100644 --- a/tests/audio.rs +++ b/RustRaptor/tests/audio.rs @@ -169,6 +169,103 @@ fn digital_voices_clip_together_before_being_added_to_fm() { assert_eq!(out[21], 127 * 128); } +#[test] +fn changing_fx_volume_mutes_and_restores_already_playing_samples() { + let mut mixer = mixer(); + mixer.fx_volume = 127; + mixer.sample(sample(vec![255; 4000]), 127, 128, 127, 0); + let mut before = [0; 128]; + mixer.mix(&mut before); + assert!(before[16..].iter().all(|v| *v > 0)); + mixer.fx_volume = 0; + let mut muted = [99; 256]; + mixer.mix(&mut muted); + assert!(muted.iter().all(|v| *v == 0)); + mixer.fx_volume = 127; + let mut restored = [0; 128]; + mixer.mix(&mut restored); + assert!(restored[16..].iter().all(|v| *v > 0)); + mixer.fx_volume = 32; + let mut reduced = [0; 128]; + mixer.mix(&mut reduced); + assert!(reduced[16..].iter().all(|v| *v > 0)); + assert!(reduced[16..].iter().max().unwrap() < &(restored[16..].iter().max().unwrap() / 2)); +} + +#[test] +fn music_mute_is_immediate_and_restores_an_active_score_without_muting_effects() { + let mut mixer = mixer(); + mixer.music_volume = 127; + mixer.play( + MusScore::decode(&mus(&[0x90, 188, 100, 127, 0x60], 1)).unwrap(), + false, + ); + let mut before = vec![0; 3000 * 2]; + mixer.mix(&mut before); + assert!(before.iter().any(|v| *v != 0)); + mixer.music_volume = 0; + let mut muted = [99; 512]; + mixer.mix(&mut muted); + assert!(muted.iter().all(|v| *v == 0)); + mixer.music_volume = 127; + let mut restored = vec![0; 3000 * 2]; + mixer.mix(&mut restored); + assert!(restored.iter().any(|v| *v != 0)); + mixer.music_volume = 0; + mixer.fx_volume = 127; + mixer.sample(sample(vec![255; 4000]), 127, 128, 127, 0); + mixer.mix(&mut muted); + assert!(muted[16..].iter().all(|v| *v > 0)); +} + +#[test] +fn bounded_capture_records_exact_callback_output_without_changing_it() { + let mut captured = mixer(); + let mut normal = mixer(); + captured.start_capture(1).unwrap(); + assert!(normal.start_capture(0).is_err()); + assert!(normal.start_capture(121).is_err()); + for mixer in [&mut captured, &mut normal] { + mixer.fx_volume = 127; + mixer.sample( + sample((0..255).cycle().take(12000).collect()), + 0, + 128, + 100, + 0, + ); + } + let mut actual = vec![0; 44100 * 4]; + let mut expected = vec![0; actual.len()]; + captured.mix(&mut actual); + normal.mix(&mut expected); + assert_eq!(actual, expected); + let capture = captured.take_capture().unwrap(); + assert_eq!(capture.rate, 44100); + assert_eq!(capture.samples.len(), 44100 * 2); + assert_eq!(capture.samples, actual[..44100 * 2]); + assert!(capture.samples.chunks_exact(2).any(|s| s[0] != 0)); + // Native DSP panning deliberately retains a small opposite-channel bleed. + let left: i64 = capture + .samples + .chunks_exact(2) + .map(|s| i64::from(s[0]).abs()) + .sum(); + let right: i64 = capture + .samples + .chunks_exact(2) + .map(|s| i64::from(s[1]).abs()) + .sum(); + assert!(left > right * 4); + let wav = raptor_replica::sound::encode_capture(&capture).unwrap(); + assert_eq!(&wav[..4], b"RIFF"); + assert_eq!(u16::from_le_bytes(wav[22..24].try_into().unwrap()), 2); + assert_eq!( + u32::from_le_bytes(wav[40..44].try_into().unwrap()), + 44100 * 4 + ); +} + #[test] fn malformed_scores_controller_values_and_missing_end_events_are_rejected() { for body in [ @@ -198,7 +295,7 @@ fn original_music_and_sound_assets_decode_locally() { use raptor_replica::assets::Catalog; let root = std::env::var_os("RAPTOR_TEST_DATA") .map(std::path::PathBuf::from) - .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("data")); + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../data")); let catalog = Catalog::open(root).unwrap(); let mut music = 0; let mut sounds = 0; @@ -323,7 +420,7 @@ fn compile_reference(directory: &Path) -> std::path::PathBuf { compile .args(["-std=c++17", "-O2"]) .arg("-I") - .arg(root.join("raptor/src")); + .arg(root.join("../raptor/src")); compile.arg("-I").arg("/usr/include/SDL2"); if let Some(home) = std::env::var_os("HOME") { compile @@ -332,7 +429,7 @@ fn compile_reference(directory: &Path) -> std::path::PathBuf { } compile.arg(&source); for path in ["i_oplmusic.cpp", "opl3.cpp", "musapi.cpp", "dspapi.cpp"] { - compile.arg(root.join("raptor/src").join(path)); + compile.arg(root.join("../raptor/src").join(path)); } let result = compile.arg("-o").arg(&binary).output().unwrap(); assert!( @@ -350,7 +447,7 @@ fn original_intro_pcm_matches_the_retained_native_reference() { let temp = tempfile::tempdir().unwrap(); let root = std::env::var_os("RAPTOR_TEST_DATA") .map(std::path::PathBuf::from) - .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("data")); + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../data")); let catalog = Catalog::open(root).unwrap(); let bank = catalog.named("GENMIDI_OP2").unwrap(); let song = catalog.named("RINTRO_MUS").unwrap(); diff --git a/tests/campaigns.rs b/RustRaptor/tests/campaigns.rs similarity index 98% rename from tests/campaigns.rs rename to RustRaptor/tests/campaigns.rs index 4c18056c..bbf6be48 100644 --- a/tests/campaigns.rs +++ b/RustRaptor/tests/campaigns.rs @@ -14,7 +14,7 @@ use std::{collections::BTreeSet, path::PathBuf}; fn catalog_registry() -> (Catalog, raptor_replica::content::Registry) { let root = std::env::var_os("RAPTOR_TEST_DATA") .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data")); + .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../data")); campaign_check::catalog_registry(&root).unwrap() } fn registry() -> raptor_replica::content::Registry { @@ -152,7 +152,7 @@ fn complete_demo_1_matches_every_reference_gameplay_snapshot() { let path = std::env::var_os("RAPTOR_REFERENCE_DEMO1") .map(PathBuf::from) .unwrap_or_else(|| { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".reference/cpp-demo1-full.jsonl") + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.reference/cpp-demo1-full.jsonl") }); let reference: Vec = std::fs::read_to_string(&path) .expect("produce the trace using the command in docs/campaign-validation.md") diff --git a/tests/core.rs b/RustRaptor/tests/core.rs similarity index 87% rename from tests/core.rs rename to RustRaptor/tests/core.rs index c1861b03..f1080cf4 100644 --- a/tests/core.rs +++ b/RustRaptor/tests/core.rs @@ -22,6 +22,38 @@ fn pilot_registry() -> Registry { registry } +#[test] +fn shop_pack_sales_return_half_the_unit_price_without_creating_credits() { + let mut registry = pilot_registry(); + let pack = serde_json::from_value(serde_json::json!({ + "items":[{"id":"fixture:repair_pack", "name":"Three repairs", "price":900, + "initial_count":3, "max_count":8, "consumable":true}] + })) + .unwrap(); + registry.apply(pack, "fixture").unwrap(); + registry.validate().unwrap(); + let id = "fixture:repair_pack".into(); + let item = ®istry.items[&id]; + assert_eq!(item.resale_price(), 150); + let mut pilot = Pilot { + credits: 10_000, + ..Pilot::default() + }; + pilot.buy(&id, ®istry).unwrap(); + pilot.buy(&id, ®istry).unwrap(); + assert_eq!(pilot.inventory[&id], 6); + for remaining in (0..6).rev() { + pilot.sell(&id, ®istry).unwrap(); + assert_eq!(pilot.item_count(&id), remaining); + } + assert_eq!(pilot.credits, 9_100); + assert!(!pilot.inventory.contains_key(&id)); + let credits = pilot.credits; + assert!(pilot.sell(&id, ®istry).is_err()); + assert_eq!(pilot.credits, credits); + assert_eq!(registry.items[&"base:energy".into()].resale_price(), 5_000); +} + #[test] fn training_graduation_and_sector_cycles_preserve_original_progression() { let registry = pilot_registry(); @@ -196,7 +228,9 @@ fn importing_settings_preserves_source_and_uses_sdl_key_names() { let config = Config::import_ini(&path).unwrap(); assert_eq!(config.scale, 3); assert_eq!(config.bindings["fire"], "Left Ctrl"); - assert_eq!(config.bindings["special"], "Left Alt"); + assert_eq!(config.bindings["special"], "Z"); + assert_eq!(config.bindings["cycle"], "Left Alt"); + assert_eq!(config.bindings["bomb"], "Space"); assert_eq!(std::fs::read(path).unwrap(), original); #[cfg(feature = "desktop")] for key in config.bindings.values() { diff --git a/tests/enemies_mods.rs b/RustRaptor/tests/enemies_mods.rs similarity index 98% rename from tests/enemies_mods.rs rename to RustRaptor/tests/enemies_mods.rs index 03f913e2..edf0ff73 100644 --- a/tests/enemies_mods.rs +++ b/RustRaptor/tests/enemies_mods.rs @@ -45,9 +45,7 @@ fn wav() -> Vec { } fn install_fixture(root: &Path, id: &str) { - let source = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("examples/mods") - .join(id); + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("mods").join(id); let output = root.join(id); fs::create_dir_all(output.join("scripts")).unwrap(); fs::create_dir_all(output.join("art")).unwrap(); @@ -422,7 +420,7 @@ fn mission_defeat_counts_reset_while_persistent_script_statistics_continue() { #[test] fn shipped_enemy_assets_are_valid_and_aligned() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/mods"); + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("mods"); let selected: Vec<_> = ENEMIES.iter().map(|id| id.to_string()).collect(); let discovery = modding::discover(&root); ActiveMods::build(&base(), &discovery, &selected, None).unwrap(); diff --git a/tests/first_mission.rs b/RustRaptor/tests/first_mission.rs similarity index 99% rename from tests/first_mission.rs rename to RustRaptor/tests/first_mission.rs index 783e58a4..dfb4b14a 100644 --- a/tests/first_mission.rs +++ b/RustRaptor/tests/first_mission.rs @@ -128,10 +128,10 @@ fn original_first_mission_is_won_with_a_fresh_pilot() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let path = std::env::var_os("RAPTOR_TEST_DATA") .map(PathBuf::from) - .unwrap_or_else(|| root.join("data")); + .unwrap_or_else(|| root.join("../data")); let catalog = Catalog::open(&path).unwrap(); let registry = Registry::base(&catalog).unwrap(); - let reference = root.join(".reference"); + let reference = root.join("../.reference"); fs::create_dir_all(&reference).unwrap(); for difficulty in [1, 2] { let pilot = Pilot { diff --git a/tests/gameplay.rs b/RustRaptor/tests/gameplay.rs similarity index 100% rename from tests/gameplay.rs rename to RustRaptor/tests/gameplay.rs diff --git a/tests/iron_coast.rs b/RustRaptor/tests/iron_coast.rs similarity index 81% rename from tests/iron_coast.rs rename to RustRaptor/tests/iron_coast.rs index 768c28d1..35c02347 100644 --- a/tests/iron_coast.rs +++ b/RustRaptor/tests/iron_coast.rs @@ -95,7 +95,7 @@ fn fixture_resources(value: &Value, root: &Path) { fn active() -> (tempfile::TempDir, ActiveMods) { let started = std::time::Instant::now(); let temporary = tempfile::tempdir().unwrap(); - let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/mods"); + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods"); for id in PACKAGES { let original = source.join(id); let root = temporary.path().join(id); @@ -243,20 +243,31 @@ fn play(use_trial: bool) -> (World, Vec<(u64, String)>) { #[test] fn full_inventory_can_start_without_deleting_saved_equipment() { let (_temporary, mut active) = active(); - let mut pilot = Pilot::default(); - for n in 0..18 { - pilot - .inventory - .insert(format!("base:fixture_item_{n}").as_str().into(), 1); + for reserves in [0, 4] { + let mut pilot = Pilot::default(); + if reserves > 0 { + pilot.inventory.insert("base:super_shield".into(), 100); + pilot.super_shield_reserves = vec![100; reserves]; + } + while pilot.inventory_slots() < 20 { + let n = pilot.inventory.len(); + pilot + .inventory + .insert(format!("base:fixture_item_{n}").as_str().into(), 1); + } + assert_eq!(pilot.inventory_slots(), 20); + let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap(); + world + .step(Input::default(), &active.registry, &mut active.scripts) + .unwrap(); + assert_eq!(world.player.inventory, pilot.inventory); + assert_eq!( + world.player.super_shield_reserves, + pilot.super_shield_reserves + ); + assert_eq!(world.objectives["iron_coast_mission_1:trial_issued"], 0); + assert_eq!(world.outcome, Outcome::Playing); } - assert_eq!(pilot.inventory.len(), 20); - let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap(); - world - .step(Input::default(), &active.registry, &mut active.scripts) - .unwrap(); - assert_eq!(world.player.inventory, pilot.inventory); - assert_eq!(world.objectives["iron_coast_mission_1:trial_issued"], 0); - assert_eq!(world.outcome, Outcome::Playing); } #[test] @@ -328,10 +339,68 @@ fn trial_pulse_cannon_completes_and_preserves_scripted_weapon_state() { world.objectives ); assert!( - world + !world .player .inventory .contains_key(&"pulse_cannon:equipment".into()) ); assert!(world.player.credits >= 5000); } + +#[test] +fn trial_is_a_loan_and_cannot_be_repeatedly_sold_after_victory() { + let (_temporary, mut active) = active(); + let mut pilot = Pilot::default(); + for _ in 0..2 { + let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap(); + world + .step(Input::default(), &active.registry, &mut active.scripts) + .unwrap(); + assert_eq!(world.player.inventory[&"pulse_cannon:equipment".into()], 1); + world + .objectives + .insert("iron_coast_turret:defeated".into(), 2); + world + .objectives + .insert("iron_coast_boss:defeated".into(), 1); + world + .step(Input::default(), &active.registry, &mut active.scripts) + .unwrap(); + assert_eq!(world.outcome, Outcome::Complete); + pilot = world.pilot(&pilot); + assert!( + !pilot + .inventory + .contains_key(&"pulse_cannon:equipment".into()) + ); + let credits = pilot.credits; + assert!( + pilot + .sell(&"pulse_cannon:equipment".into(), &active.registry) + .is_err() + ); + assert_eq!(pilot.credits, credits); + } +} + +#[test] +fn owned_cannon_survives_trial_completion_without_replacement() { + let (_temporary, mut active) = active(); + let mut pilot = Pilot::default(); + pilot.inventory.insert("pulse_cannon:equipment".into(), 1); + let mut world = World::new(&MISSION.into(), &pilot, &active.registry).unwrap(); + world + .step(Input::default(), &active.registry, &mut active.scripts) + .unwrap(); + world + .objectives + .insert("iron_coast_turret:defeated".into(), 2); + world + .objectives + .insert("iron_coast_boss:defeated".into(), 1); + world + .step(Input::default(), &active.registry, &mut active.scripts) + .unwrap(); + assert_eq!(world.outcome, Outcome::Complete); + assert_eq!(world.player.inventory[&"pulse_cannon:equipment".into()], 1); +} diff --git a/RustRaptor/tests/mod_selection.rs b/RustRaptor/tests/mod_selection.rs new file mode 100644 index 00000000..455b106c --- /dev/null +++ b/RustRaptor/tests/mod_selection.rs @@ -0,0 +1,181 @@ +use raptor_replica::{ + mod_selection::{plan_disable, plan_enable}, + modding::{self, Discovery, InstalledMod, Manifest}, +}; +use std::{collections::BTreeMap, path::PathBuf}; + +fn ids(values: &[&str]) -> Vec { + values.iter().map(|id| (*id).into()).collect() +} +fn package(id: &str, dependencies: &[(&str, &str)]) -> InstalledMod { + InstalledMod { + root: PathBuf::from(id), + hash: String::new(), + manifest: Manifest { + id: id.into(), + name: id.into(), + version: "1.0.0".into(), + api_version: 1, + description: String::new(), + content: "content.json".into(), + scripts: vec![], + conflicts: vec![], + dependencies: dependencies + .iter() + .map(|(id, req)| ((*id).into(), (*req).into())) + .collect(), + }, + } +} +fn graph() -> Discovery { + Discovery { + mods: BTreeMap::from([ + ("shared".into(), package("shared", &[])), + ("weapon".into(), package("weapon", &[("shared", "^1")])), + ("enemy".into(), package("enemy", &[("shared", "^1")])), + ( + "mission".into(), + package("mission", &[("weapon", "^1"), ("enemy", "^1")]), + ), + ("sequel".into(), package("sequel", &[("mission", "^1")])), + ("other".into(), package("other", &[])), + ]), + ..Default::default() + } +} +#[test] +fn enabling_a_mission_selects_recursive_shared_dependencies_once() { + let selected = ids(&["other"]); + let planned = plan_enable(&graph(), &selected, "mission").unwrap(); + assert_eq!( + planned, + ids(&["other", "shared", "enemy", "weapon", "mission"]) + ); + assert_eq!(selected, ids(&["other"])); + assert_eq!(plan_enable(&graph(), &planned, "mission").unwrap(), planned); +} +#[test] +fn unavailable_incompatible_api_cycles_and_conflicts_leave_selection_unchanged() { + let initial = ids(&["other"]); + let check = |graph: &Discovery, expected: &str| { + let error = plan_enable(graph, &initial, "mission") + .unwrap_err() + .to_string(); + assert!(error.contains(expected), "{error}"); + assert_eq!(initial, ids(&["other"])); + }; + let mut g = graph(); + g.mods.remove("shared"); + check(&g, "install it"); + let mut g = graph(); + g.mods.get_mut("shared").unwrap().manifest.version = "2.0.0".into(); + check(&g, "installed 2.0.0"); + let mut g = graph(); + g.mods.get_mut("shared").unwrap().manifest.api_version = 2; + check(&g, "requires API 2"); + let mut g = graph(); + g.mods + .get_mut("shared") + .unwrap() + .manifest + .dependencies + .insert("mission".into(), "^1".into()); + check(&g, "cyclic"); + let mut g = graph(); + g.mods + .get_mut("other") + .unwrap() + .manifest + .conflicts + .push("weapon".into()); + check(&g, "conflicts"); +} +#[test] +fn disable_plan_removes_transitive_dependents_but_preserves_unrelated_mods() { + let initial = ids(&["other", "shared", "enemy", "weapon", "mission", "sequel"]); + let plan = plan_disable(&graph(), &initial, "weapon"); + assert_eq!( + plan.removed, + ids(&["shared", "enemy", "weapon", "mission", "sequel"]) + ); + assert_eq!(plan.enabled, ids(&["other"])); + // Merely opening the confirmation must not change the pending selection. + assert_eq!( + initial, + ids(&["other", "shared", "enemy", "weapon", "mission", "sequel"]) + ); + assert_eq!( + modding::order(&graph(), &plan.enabled).unwrap(), + plan.enabled + ); +} +#[test] +fn disabling_a_shared_dependency_cascades_across_both_branches() { + let initial = plan_enable(&graph(), &ids(&["other"]), "sequel").unwrap(); + let plan = plan_disable(&graph(), &initial, "shared"); + assert_eq!(plan.enabled, ids(&["other"])); + assert_eq!(plan.removed.len(), 5); + assert!( + plan_disable(&graph(), &initial, "absent") + .removed + .is_empty() + ); +} +#[test] +fn disabling_a_missing_installed_package_can_still_remove_its_dependents() { + let mut g = graph(); + g.mods.remove("shared"); + let initial = ids(&["shared", "weapon", "other"]); + let plan = plan_disable(&g, &initial, "shared"); + assert_eq!(plan.removed, ids(&["shared", "weapon"])); + assert_eq!(plan.enabled, ids(&["other"])); +} +#[test] +fn selecting_shipped_iron_coast_mission_selects_all_six_real_dependencies() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("mods"); + let discovery = modding::discover(&root); + assert!(discovery.errors.is_empty(), "{:?}", discovery.errors); + let enabled = plan_enable(&discovery, &[], "iron_coast_mission_1").unwrap(); + assert_eq!(enabled.len(), 7); + assert_eq!(enabled.last().unwrap(), "iron_coast_mission_1"); + for id in [ + "iron_coast_scout", + "iron_coast_turret", + "iron_coast_boss", + "pulse_cannon", + "seeker_swarm", + "ion_lance", + ] { + assert!(enabled.contains(&id.to_string())); + let removal = plan_disable(&discovery, &enabled, id); + assert_eq!(removal.removed, enabled); + assert!(removal.enabled.is_empty()); + } + assert_eq!( + plan_disable(&discovery, &enabled, "iron_coast_mission_1").removed, + enabled + ); +} + +#[test] +fn another_remaining_campaign_preserves_the_shared_dependencies_it_needs() { + let mut g = graph(); + g.mods.insert( + "other_campaign".into(), + package("other_campaign", &[("enemy", "^1")]), + ); + let mut enabled = plan_enable(&g, &[], "mission").unwrap(); + enabled = plan_enable(&g, &enabled, "other_campaign").unwrap(); + let plan = plan_disable(&g, &enabled, "mission"); + assert_eq!(plan.removed, ids(&["weapon", "mission"])); + assert_eq!(plan.enabled, ids(&["shared", "enemy", "other_campaign"])); + assert_eq!(modding::order(&g, &plan.enabled).unwrap(), plan.enabled); +} + +#[test] +fn a_standalone_mod_has_exactly_one_removal_for_the_instant_toggle() { + let enabled = plan_enable(&graph(), &ids(&["other"]), "mission").unwrap(); + let plan = plan_disable(&graph(), &enabled, "other"); + assert_eq!(plan.removed, ids(&["other"])); + assert_eq!(plan.enabled, ids(&["shared", "enemy", "weapon", "mission"])); +} diff --git a/tests/modding.rs b/RustRaptor/tests/modding.rs similarity index 99% rename from tests/modding.rs rename to RustRaptor/tests/modding.rs index 30cdddb6..5df95709 100644 --- a/tests/modding.rs +++ b/RustRaptor/tests/modding.rs @@ -599,7 +599,7 @@ fn saved_script_state_and_rng_resume_the_same_callback_sequence() { #[test] fn example_mod_compiles_and_its_weapon_enemy_item_and_mission_hooks_execute() { - let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/mods"); + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("mods"); let mut active = ActiveMods::build(&base(), &modding::discover(&dir), &ids(&["aurora"]), None).unwrap(); assert!( diff --git a/RustRaptor/tests/shields.rs b/RustRaptor/tests/shields.rs new file mode 100644 index 00000000..7b0c4955 --- /dev/null +++ b/RustRaptor/tests/shields.rs @@ -0,0 +1,532 @@ +//! Original shield-module inventory and recharge behavior, with synthetic data. +use raptor_replica::{ + content::{ContentId, ContentPack, Registry}, + modding::{Command, ScriptRuntime}, + persistence::Pilot, + simulation::{Input, Pickup, World}, +}; +use serde_json::json; + +fn fixture() -> Registry { + let mut items = vec![ + json!({"id":"base:forward_guns","name":"Guns","price":12000,"weapon":"base:shot","legacy":0}), + json!({"id":"base:energy","name":"Energy","price":10000,"max_count":100,"initial_count":25,"shield":25,"legacy":16}), + json!({"id":"base:super_shield","name":"Super Shield","price":78500,"max_count":100,"initial_count":100,"legacy":15}), + ]; + for n in 0..20 { + items.push(json!({"id":format!("base:filler_{n}"),"name":"Inventory slot","price":1})); + } + let pack: ContentPack = serde_json::from_value(json!({ + "items":items, + "weapons":[{"id":"base:shot","name":"Gun","damage":1,"speed":8,"cooldown":5,"behavior":"straight","target":"all"}], + "missions":[{"id":"base:sector_1_wave_1","name":"Shield fixture","duration":100000}] + })).unwrap(); + let mut registry = Registry::default(); + registry.apply(pack, "base").unwrap(); + registry.validate().unwrap(); + registry +} +fn super_id() -> ContentId { + "base:super_shield".into() +} +fn shielded(active: u32, reserves: &[u32]) -> Pilot { + let mut pilot = Pilot { + credits: 2_000_000, + difficulty: 2, + ..Pilot::default() + }; + pilot.inventory.insert(super_id(), active); + pilot.super_shield_reserves = reserves.to_vec(); + pilot +} +fn mission_world(pilot: &Pilot, registry: &Registry) -> World { + World::new(&pilot.mission, pilot, registry).unwrap() +} +fn hit(world: &mut World, damage: i32, registry: &Registry) { + world + .apply_commands( + &[Command::Damage { + entity: 0, + amount: damage, + }], + registry, + ) + .unwrap(); +} +fn idle(world: &mut World, ticks: usize, registry: &Registry) { + let mut scripts = ScriptRuntime::new(); + for _ in 0..ticks { + world + .step(Input::default(), registry, &mut scripts) + .unwrap(); + } +} +fn collect(world: &mut World, item: &str, registry: &Registry) { + world.pickups.push(Pickup { + entity: 9000, + item: item.into(), + x: world.player.x, + y: world.player.y, + legacy: None, + }); + idle(world, 1, registry); + assert!(world.pickups.is_empty()); +} + +#[test] +fn purchases_preserve_active_charge_and_enforce_five_modules_and_twenty_slots() { + let registry = fixture(); + let mut pilot = shielded(17, &[]); + for _ in 0..4 { + pilot.buy(&super_id(), ®istry).unwrap(); + } + assert_eq!(pilot.inventory[&super_id()], 17); + assert_eq!(pilot.super_shield_reserves, vec![100; 4]); + assert_eq!(pilot.item_count(&super_id()), 5); + assert_eq!(pilot.inventory_slots(), 7); + let before = serde_json::to_value(&pilot).unwrap(); + assert!(pilot.buy(&super_id(), ®istry).is_err()); + assert_eq!(serde_json::to_value(&pilot).unwrap(), before); + let mut full = shielded(100, &[100]); + for n in 0..16 { + full.inventory + .insert(format!("base:filler_{n}").as_str().into(), 1); + } + assert_eq!(full.inventory_slots(), 20); + assert!(full.buy(&super_id(), ®istry).is_err()); + full.inventory.insert("base:energy".into(), 75); + full.buy(&"base:energy".into(), ®istry).unwrap(); + assert_eq!(full.inventory[&ContentId::from("base:energy")], 100); + full.validate(®istry, &[]).unwrap(); +} + +#[test] +fn selling_removes_full_equipped_module_and_selects_first_reserve() { + let registry = fixture(); + let mut pilot = shielded(100, &[37, 100]); + let credits = pilot.credits; + pilot.sell(&super_id(), ®istry).unwrap(); + assert_eq!(pilot.credits, credits + 39250); + assert_eq!(pilot.inventory[&super_id()], 37); + assert_eq!(pilot.super_shield_reserves, vec![100]); + assert_eq!(pilot.item_count(&super_id()), 2); + let before = serde_json::to_value(&pilot).unwrap(); + assert!( + pilot.sell(&super_id(), ®istry).is_err(), + "partial active module cannot sell a reserve instead" + ); + assert_eq!(serde_json::to_value(&pilot).unwrap(), before); + pilot.inventory.insert(super_id(), 100); + pilot.sell(&super_id(), ®istry).unwrap(); + pilot.sell(&super_id(), ®istry).unwrap(); + assert_eq!(pilot.item_count(&super_id()), 0); + assert!(!pilot.inventory.contains_key(&super_id())); +} + +#[test] +fn normal_energy_sales_remove_one_purchase_and_retain_positive_shield() { + let registry = fixture(); + let mut pilot = Pilot::default(); + pilot.sell(&"base:energy".into(), ®istry).unwrap(); + assert_eq!(pilot.inventory[&ContentId::from("base:energy")], 75); + assert_eq!(pilot.credits, 5000); + pilot.inventory.insert("base:energy".into(), 26); + pilot.sell(&"base:energy".into(), ®istry).unwrap(); + assert_eq!(pilot.inventory[&ContentId::from("base:energy")], 1); + assert!(pilot.sell(&"base:energy".into(), ®istry).is_err()); +} + +#[test] +fn hits_do_not_spill_into_reserves_or_normal_shields_and_exact_zero_remains_equipped() { + let registry = fixture(); + let pilot = shielded(5, &[37, 0, 100]); + let mut world = mission_world(&pilot, ®istry); + hit(&mut world, 5, ®istry); + assert_eq!(world.player.super_shield, 0); + assert!(world.player.inventory.contains_key(&super_id())); + assert_eq!(world.player.super_shield_reserves, vec![37, 0, 100]); + hit(&mut world, 9000, ®istry); + assert_eq!(world.player.super_shield, 37); + assert_eq!(world.player.shield, 100); + hit(&mut world, 38, ®istry); + assert_eq!( + world.player.super_shield, 0, + "a zero-charge reserve may become equipped" + ); + assert!(world.player.inventory.contains_key(&super_id())); + hit(&mut world, 1, ®istry); + assert_eq!(world.player.super_shield, 100); + hit(&mut world, 101, ®istry); + assert!(!world.player.inventory.contains_key(&super_id())); + assert!(world.player.super_shield_reserves.is_empty()); + assert_eq!(world.player.shield, 100); + hit(&mut world, 3, ®istry); + assert_eq!(world.player.shield, 97); + let saved = world.pilot(&pilot); + assert!(saved.super_shield_reserves.is_empty()); + assert_eq!(saved.inventory[&ContentId::from("base:energy")], 97); +} + +#[test] +fn rookie_damage_is_halved_before_module_depletion_and_round_trips() { + let registry = fixture(); + let mut pilot = shielded(5, &[71, 99]); + pilot.difficulty = 0; + let mut world = mission_world(&pilot, ®istry); + hit(&mut world, 7, ®istry); + assert_eq!(world.player.super_shield, 2); + hit(&mut world, 4, ®istry); + assert_eq!(world.player.super_shield, 0); + hit(&mut world, 5, ®istry); + assert_eq!(world.player.super_shield, 71); + let saved = world.pilot(&pilot); + saved.validate(®istry, &[]).unwrap(); + let restored = mission_world(&saved, ®istry); + assert_eq!(restored.player.super_shield, 71); + assert_eq!(restored.player.super_shield_reserves, vec![99]); +} + +#[test] +fn pickups_add_modules_without_refilling_active_charge_and_energy_only_charges_active() { + let registry = fixture(); + let pilot = shielded(20, &[100; 4]); + let mut world = mission_world(&pilot, ®istry); + collect(&mut world, "base:super_shield", ®istry); + assert_eq!(world.player.super_shield, 20); + assert_eq!( + world.player.super_shield_reserves, + vec![100; 5], + "native pickup capacity is slots; five is the shop limit" + ); + collect(&mut world, "base:energy", ®istry); + assert_eq!(world.player.super_shield, 26); + assert_eq!(world.player.super_shield_reserves, vec![100; 5]); + world.player.shield = 90; + collect(&mut world, "base:energy", ®istry); + assert_eq!(world.player.shield, 100); + assert_eq!( + world.player.super_shield, 26, + "normal-shield overflow is discarded, not charged into a module" + ); + world.player.super_shield = 0; + world.player.inventory.insert(super_id(), 0); + collect(&mut world, "base:energy", ®istry); + assert_eq!( + world.player.super_shield, 0, + "normal energy cannot revive an empty equipped module" + ); + let mut full = shielded(20, &[99]); + for n in 0..16 { + full.inventory + .insert(format!("base:filler_{n}").as_str().into(), 1); + } + let mut world = mission_world(&full, ®istry); + collect(&mut world, "base:super_shield", ®istry); + assert_eq!(world.player.super_shield, 20); + assert_eq!(world.player.super_shield_reserves, vec![99]); +} + +#[test] +fn idle_recharge_uses_97_steps_pauses_on_weapon_attempts_and_preserves_rng() { + let registry = fixture(); + let mut pilot = shielded(44, &[88]); + pilot.inventory.insert("base:energy".into(), 75); + let mut world = mission_world(&pilot, ®istry); + let rng = world.rng; + idle(&mut world, 96, ®istry); + assert_eq!(world.player.shield, 75); + assert_eq!(world.shield_recharge_ticks, 96); + let checkpoint = world.pilot(&pilot); + let mut world = mission_world(&checkpoint, ®istry); + idle(&mut world, 1, ®istry); + assert_eq!(world.player.shield, 76); + assert_eq!(world.player.super_shield, 44); + assert_eq!(world.player.super_shield_reserves, vec![88]); + assert_eq!(world.rng, rng); + idle(&mut world, 96, ®istry); + world + .step( + Input { + fire: true, + ..Input::default() + }, + ®istry, + &mut ScriptRuntime::new(), + ) + .unwrap(); + assert_eq!(world.shield_recharge_ticks, 0); + idle(&mut world, 96, ®istry); + assert_eq!(world.player.shield, 76); + idle(&mut world, 1, ®istry); + assert_eq!(world.player.shield, 77); + world.player.shield = 100; + idle(&mut world, 97, ®istry); + assert_eq!( + world.player.super_shield, 44, + "one-unit idle recharge rounds to zero for super shields" + ); + let mut ace = pilot.clone(); + ace.difficulty = 3; + let mut ace = mission_world(&ace, ®istry); + idle(&mut ace, 200, ®istry); + assert_eq!(ace.player.shield, 75); + let mut ending = mission_world(&pilot, ®istry); + ending.end_timer = Some(500); + idle(&mut ending, 97, ®istry); + assert_eq!(ending.player.shield, 75); +} + +#[test] +fn charge_unit_script_commands_preserve_reserves_and_rollback_all_module_state() { + let registry = fixture(); + let pilot = shielded(12, &[44, 100]); + let mut world = mission_world(&pilot, ®istry); + world + .apply_commands( + &[ + Command::GiveItem { + item: super_id().0, + count: 16, + }, + Command::ConsumeItem { + item: super_id().0, + count: 18, + }, + ], + ®istry, + ) + .unwrap(); + assert_eq!(world.player.super_shield, 10); + assert_eq!(world.player.inventory[&super_id()], 10); + assert_eq!(world.player.super_shield_reserves, vec![44, 100]); + let before = serde_json::to_value(&world).unwrap(); + assert!( + world + .apply_commands( + &[ + Command::Damage { + entity: 0, + amount: 15 + }, + Command::Velocity { + entity: 999, + vx: 1, + vy: 1 + } + ], + ®istry + ) + .is_err() + ); + assert_eq!(serde_json::to_value(&world).unwrap(), before); + world + .apply_commands( + &[ + Command::ConsumeItem { + item: super_id().0, + count: 10, + }, + Command::ConsumeItem { + item: "base:energy".into(), + count: 100, + }, + ], + ®istry, + ) + .unwrap(); + assert_eq!(world.player.super_shield, 0); + assert_eq!(world.player.shield, 0); + assert_eq!(world.player.inventory[&super_id()], 0); + assert_eq!(world.player.inventory[&ContentId::from("base:energy")], 0); + assert_eq!(world.player.super_shield_reserves, vec![44, 100]); +} + +#[test] +fn consumed_equipment_releases_slot_selects_next_special_and_rolls_back_atomically() { + let mut registry = fixture(); + for n in 0..2 { + let item = registry + .items + .get_mut(&ContentId::from(format!("base:filler_{n}").as_str())) + .unwrap(); + item.special = true; + item.weapon = Some("base:shot".into()); + } + let first = ContentId::from("base:filler_0"); + let second = ContentId::from("base:filler_1"); + let mut pilot = shielded(17, &[100]); + for n in 0..16 { + pilot + .inventory + .insert(format!("base:filler_{n}").as_str().into(), 1); + } + pilot.selected = Some(first.clone()); + assert_eq!(pilot.inventory_slots(), 20); + let mut world = mission_world(&pilot, ®istry); + let before = serde_json::to_value(&world).unwrap(); + let consume = Command::ConsumeItem { + item: first.0.clone(), + count: 1, + }; + assert!( + world + .apply_commands( + &[ + consume.clone(), + Command::Velocity { + entity: 999, + vx: 1, + vy: 1, + }, + ], + ®istry, + ) + .is_err() + ); + assert_eq!(serde_json::to_value(&world).unwrap(), before); + world.apply_commands(&[consume], ®istry).unwrap(); + assert!(!world.player.inventory.contains_key(&first)); + assert_eq!(world.player.selected, Some(second.clone())); + assert_eq!(world.pilot(&pilot).inventory_slots(), 19); + world + .apply_commands( + &[ + Command::GiveItem { + item: "base:filler_16".into(), + count: 1, + }, + Command::ConsumeItem { + item: second.0, + count: 1, + }, + ], + ®istry, + ) + .unwrap(); + assert_eq!(world.player.selected, None); + assert_eq!(world.pilot(&pilot).inventory_slots(), 19); + world.pilot(&pilot).validate(®istry, &[]).unwrap(); +} + +#[test] +fn version_one_saves_without_extended_fields_remain_readable_without_source_changes() { + let registry = fixture(); + let pilot = shielded(31, &[]); + let mut old = serde_json::to_value(&pilot).unwrap(); + old.as_object_mut().unwrap().remove("super_shield_reserves"); + old.as_object_mut().unwrap().remove("shield_recharge_ticks"); + let bytes = serde_json::to_vec(&old).unwrap(); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("old-v1.json"); + std::fs::write(&path, &bytes).unwrap(); + let saved = Pilot::load(&path, ®istry, &[]).unwrap(); + assert_eq!(saved.item_count(&super_id()), 1); + assert!(saved.super_shield_reserves.is_empty()); + assert_eq!(saved.shield_recharge_ticks, 0); + let mut world = mission_world(&saved, ®istry); + hit(&mut world, 12, ®istry); + assert_eq!(world.pilot(&saved).inventory[&super_id()], 19); + assert_eq!(std::fs::read(path).unwrap(), bytes); + let mut bad = shielded(100, &[101]); + assert!(bad.validate(®istry, &[]).is_err()); + bad.super_shield_reserves = vec![100]; + bad.inventory.remove(&super_id()); + assert!(bad.validate(®istry, &[]).is_err()); +} + +#[test] +fn failed_callback_restores_module_order_charge_recharge_and_script_state() { + let mut registry = fixture(); + registry + .missions + .get_mut(&ContentId::from("base:sector_1_wave_1")) + .unwrap() + .script = Some("failed.rhai".into()); + let mut pilot = shielded(12, &[44, 100]); + pilot.shield_recharge_ticks = 95; + let mut world = mission_world(&pilot, ®istry); + let mut scripts = ScriptRuntime::new(); + scripts + .add( + "base", + "failed.rhai", + r#" + fn on_mission_start(e, s, r) { + #{commands:[#{op:"damage",entity:0,amount:15}, + #{op:"velocity",entity:999,vx:1,vy:1}],state:#{fired:true}} + } + "#, + ) + .unwrap(); + let before = serde_json::to_value(&world).unwrap(); + let states = scripts.states.clone(); + let rng = scripts.rng.clone(); + assert!( + world + .step(Input::default(), ®istry, &mut scripts) + .is_err() + ); + assert_eq!(serde_json::to_value(&world).unwrap(), before); + assert_eq!(scripts.states, states); + assert_eq!(scripts.rng, rng); +} + +fn encrypt(bytes: &[u8]) -> Vec { + let key = b"CASTLE"; + let mut index = 25 % key.len(); + let mut previous = key[index]; + bytes + .iter() + .map(|clear| { + let byte = clear.wrapping_add(key[index]).wrapping_add(previous); + previous = byte; + index = (index + 1) % key.len(); + byte + }) + .collect() +} +fn legacy_module_save(modules: &[(i32, i32)]) -> Vec { + let mut header = vec![0_u8; 88]; + for (offset, value) in [ + (40, -1_i32), + (60, modules.len() as i32 + 2), + (64, 2), + (68, 2), + (72, 2), + (76, 2), + ] { + header[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + let mut records = vec![(1_i32, 0_i32, 1_i32), (75, 16, 1)]; + records.extend(modules.iter().map(|&(charge, inuse)| (charge, 15, inuse))); + let mut body = vec![]; + for (charge, kind, inuse) in records { + let mut record = vec![0_u8; 24]; + for (offset, value) in [(8, charge), (12, kind), (20, inuse)] { + record[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + body.extend(record); + } + [encrypt(&header), encrypt(&body)].concat() +} +#[test] +fn legacy_import_preserves_independent_charges_equipped_flag_and_inventory_order() { + let registry = fixture(); + let bytes = legacy_module_save(&[(11, 0), (22, 0), (100, 1)]); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("CHAR0000.FIL"); + std::fs::write(&path, &bytes).unwrap(); + let pilot = Pilot::import_legacy(&path, ®istry).unwrap(); + assert_eq!(pilot.inventory[&super_id()], 100); + assert_eq!(pilot.super_shield_reserves, vec![11, 22]); + assert_eq!(pilot.item_count(&super_id()), 3); + let mut world = mission_world(&pilot, ®istry); + hit(&mut world, 101, ®istry); + assert_eq!(world.player.super_shield, 11); + assert_eq!(world.player.super_shield_reserves, vec![22]); + let output = temp.path().join("rust.json"); + world.pilot(&pilot).save(&output).unwrap(); + let restored = Pilot::load(&output, ®istry, &[]).unwrap(); + assert_eq!(restored.item_count(&super_id()), 2); + assert_eq!(std::fs::read(path).unwrap(), bytes); +} diff --git a/RustRaptor/tests/sound.rs b/RustRaptor/tests/sound.rs new file mode 100644 index 00000000..0b2cdb67 --- /dev/null +++ b/RustRaptor/tests/sound.rs @@ -0,0 +1,330 @@ +use raptor_replica::{ + content::{ContentPack, Registry}, + modding::{self, ActiveMods, Command, ScriptRuntime}, + persistence::Pilot, + simulation::{Input, Outcome, World}, + sound, +}; +use serde_json::json; + +fn digital_patch() -> Vec { + let data: Vec<_> = (0..4000) + .map(|i| if i % 20 < 10 { 240 } else { 16 }) + .collect(); + let mut bytes = vec![3, 0]; + bytes.extend(11025_u16.to_le_bytes()); + bytes.extend(((data.len() + 32) as i32).to_le_bytes()); + bytes.extend([0; 16]); + bytes.extend(data); + bytes.extend([0; 16]); + bytes +} +fn original_catalog() -> (tempfile::TempDir, raptor_replica::assets::Catalog) { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("marker"), []).unwrap(); + let bytes = digital_patch(); + std::fs::write(root.path().join("digital"), &bytes).unwrap(); + std::fs::write( + root.path().join("INDEX"), + format!("00090 0 marker\n00094 {} digital\n", bytes.len()), + ) + .unwrap(); + let catalog = raptor_replica::assets::Catalog::open(root.path()).unwrap(); + (root, catalog) +} +fn mixer() -> raptor_replica::audio::Mixer { + let mut genmidi = b"#OPL_II#".to_vec(); + genmidi.resize(8 + 175 * 36, 0); + let mut mixer = raptor_replica::audio::Mixer::new(44100, &genmidi).unwrap(); + mixer.fx_volume = 127; + mixer +} +#[test] +fn original_marker_resolves_digital_variant_and_keeps_decoded_cache() { + let (root, catalog) = original_catalog(); + assert!(raptor_replica::audio::DigitalSample::decode(&catalog.read(144).unwrap()).is_err()); + let mut bank = sound::SoundBank::default(); + let resolved = bank.original(&catalog, 144).unwrap(); + assert_eq!(resolved.data.len(), 4000); + assert_eq!(bank.original_count(), 1); + std::fs::remove_file(root.path().join("digital")).unwrap(); + assert_eq!(bank.original(&catalog, 144).unwrap().data, resolved.data); + assert!(bank.original(&catalog, 999).is_err()); +} + +#[test] +fn live_world_firing_reaches_the_native_digital_mixer() { + let (_root, catalog) = original_catalog(); + let mut registry = registry(); + registry + .missions + .get_mut(&Pilot::default().mission) + .unwrap() + .script = None; + let content: ContentPack = serde_json::from_value(json!({ + "weapons":[{"id":"base:test_guns","name":"Test guns","damage":2,"speed":8,"cooldown":2,"target":"all","behavior":"straight","legacy":0}] + })).unwrap(); + registry.apply(content, "base").unwrap(); + registry + .items + .get_mut(&"base:forward_guns".into()) + .unwrap() + .weapon = Some("base:test_guns".into()); + let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap(); + world + .step( + Input { + fire: true, + ..Input::default() + }, + ®istry, + &mut ScriptRuntime::new(), + ) + .unwrap(); + let event = world + .sound_events + .iter() + .find(|s| s.asset == 144) + .expect("firing did not emit native gun sound"); + let mut mixer = mixer(); + let mut bank = sound::SoundBank::default(); + bank.queue_original(&catalog, &mut mixer, event).unwrap(); + let mut pcm = vec![0; 4096]; + mixer.mix(&mut pcm); + assert!(pcm.iter().any(|s| *s != 0)); +} + +#[cfg(feature = "desktop")] +#[test] +fn production_sdl_callback_outputs_and_captures_resolved_original_effect() { + use sdl2::audio::AudioCallback; + let (_root, catalog) = original_catalog(); + let mut mixer = mixer(); + mixer.start_capture(1).unwrap(); + let mut bank = sound::SoundBank::default(); + bank.queue_original(&catalog, &mut mixer, &sound::ui_event("FX_GUN").unwrap()) + .unwrap(); + let mut output = [99; 4096]; + AudioCallback::callback(&mut mixer, &mut output); + assert!(output.iter().any(|s| *s != 0)); + assert!(output.chunks_exact(2).any(|s| s[0] != 0 && s[1] != 0)); + assert_eq!(mixer.take_capture().unwrap().samples, output); +} + +#[cfg(feature = "desktop")] +#[test] +#[ignore = "requires the original untracked catalog; uses only the Rust audio callback"] +fn all_original_effect_cues_reach_the_production_callback_locally() { + use raptor_replica::{assets::Catalog, audio::Mixer}; + use sdl2::audio::AudioCallback; + let root = std::env::var_os("RAPTOR_TEST_DATA") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../data")); + let catalog = Catalog::open(root).unwrap(); + let instruments = catalog.named("GENMIDI_OP2").unwrap(); + let mut bank = sound::SoundBank::default(); + bank.preload_originals(&catalog).unwrap(); + assert_eq!(bank.original_count(), 26); + let names = [ + "FX_MON1", + "FX_MON2", + "FX_MON3", + "FX_MON4", + "FX_MON5", + "FX_MON6", + "FX_DAVE", + "FX_THEME", + "FX_AIREXPLO", + "FX_AIREXPLO2", + "FX_BONUS", + "FX_CRASH", + "FX_DOOR", + "FX_FLYBY", + "FX_EGRAB", + "FX_GEXPLO", + "FX_GUN", + "FX_JETSND", + "FX_LASER", + "FX_MISSLE", + "FX_SWEP", + "FX_TURRET", + "FX_WARNING", + "FX_BOSS1", + "FX_IJETSND", + "FX_EJETSND", + "FX_INTROHIT", + "FX_INTROGUN", + "FX_ENEMYSHOT", + "FX_ENEMYLASER", + "FX_ENEMYMISSLE", + "FX_ENEMYPLASMA", + "FX_SHIT", + "FX_HIT", + "FX_NOSHOOT", + "FX_PULSE", + ]; + for name in names { + let mut mixer = Mixer::new(44100, &instruments).unwrap(); + mixer.fx_volume = 127; + bank.queue_original(&catalog, &mut mixer, &sound::ui_event(name).unwrap()) + .unwrap(); + let mut output = vec![0; 44100 * 2]; + AudioCallback::callback(&mut mixer, &mut output); + assert!(output.iter().any(|s| *s != 0), "silent original cue {name}"); + } + println!( + "36 original cue variants produced PCM through the Rust SDL callback; 26 cached digital assets" + ); +} + +fn wav(channels: u16, bits: u16, data: &[u8]) -> Vec { + let align = channels * bits / 8; + let mut bytes = b"RIFF".to_vec(); + bytes.extend((36u32 + data.len() as u32 + (data.len() as u32 % 2)).to_le_bytes()); + bytes.extend(b"WAVEfmt "); + bytes.extend(16u32.to_le_bytes()); + bytes.extend(1u16.to_le_bytes()); + bytes.extend(channels.to_le_bytes()); + bytes.extend(11025u32.to_le_bytes()); + bytes.extend((11025u32 * align as u32).to_le_bytes()); + bytes.extend(align.to_le_bytes()); + bytes.extend(bits.to_le_bytes()); + bytes.extend(b"data"); + bytes.extend((data.len() as u32).to_le_bytes()); + bytes.extend(data); + if data.len() % 2 == 1 { + bytes.push(0); + } + bytes +} +fn registry() -> Registry { + let mut registry = Registry::default(); + let pack: ContentPack=serde_json::from_value(json!({ + "items":[{"id":"base:energy","name":"Shield","price":0,"max_count":100}, + {"id":"base:forward_guns","name":"Guns","price":0}], + "missions":[{"id":"base:sector_1_wave_1","name":"Test","duration":1,"auto_complete":false,"script":"mission.rhai"}], + "sounds":[{"id":"base:test","name":"Test","file":"test.wav"}] + })).unwrap(); + registry.apply(pack, "base").unwrap(); + registry.validate().unwrap(); + registry +} +#[test] +fn wav_handles_padding_unsigned_eight_bit_and_stereo_sixteen_bit() { + let decoded = sound::decode(&wav(1, 8, &[0, 128, 255])).unwrap(); + assert_eq!(decoded.data, [0, 128, 255]); + assert_eq!(decoded.rate, 11025); + let samples: Vec<_> = [-32768i16, 32767, -32768, -32768, 32767, 32767] + .into_iter() + .flat_map(i16::to_le_bytes) + .collect(); + assert_eq!( + sound::decode(&wav(2, 16, &samples)).unwrap().data, + [128, 0, 255] + ); +} +#[test] +fn malformed_wave_ranges_formats_and_padding_fail() { + let bytes = wav(1, 8, &[128, 200, 0]); + for end in 0..bytes.len() { + assert!(sound::decode(&bytes[..end]).is_err()); + } + assert!(sound::decode(&wav(3, 8, &[0, 0, 0])).is_err()); + assert!(sound::decode(&wav(1, 32, &[0, 0, 0, 0])).is_err()); + let mut huge = bytes.clone(); + huge[40..44].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(sound::decode(&huge).is_err()); +} +#[test] +fn scripted_completion_emits_end_hook_once_and_sound_ids_are_checked() { + let registry = registry(); + let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap(); + let mut scripts = ScriptRuntime::new(); + scripts.add("base","mission.rhai",r#" + fn on_tick(event,state,random) { #{commands:[#{op:"complete"}],state:state} } + fn on_mission_end(event,state,random) { #{commands:[#{op:"play_sound",sound:"base:test"}],state:#{ended:1}} } + "#).unwrap(); + world + .step(Input::default(), ®istry, &mut scripts) + .unwrap(); + assert_eq!(world.outcome, Outcome::Complete); + assert_eq!(world.mod_sounds.len(), 1); + world + .step(Input::default(), ®istry, &mut scripts) + .unwrap(); + assert_eq!(world.mod_sounds.len(), 1); + assert!( + world + .apply_commands( + &[Command::PlaySound { + sound: "base:missing".into() + }], + ®istry + ) + .is_err() + ); +} + +#[test] +fn scripted_mod_cue_outputs_pcm_and_cache_is_cleared_when_mods_change() { + let root = tempfile::tempdir().unwrap(); + let mut registry = registry(); + registry + .asset_roots + .insert("base".into(), root.path().to_path_buf()); + let path = root.path().join("test.wav"); + std::fs::write(&path, wav(1, 8, &vec![240; 2000])).unwrap(); + let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap(); + let mut scripts = ScriptRuntime::new(); + scripts.add("base", "mission.rhai", r#" + fn on_tick(event,state,random) { #{commands:[#{op:"complete"}],state:state} } + fn on_mission_end(event,state,random) { #{commands:[#{op:"play_sound",sound:"base:test"}],state:state} } + "#).unwrap(); + world + .step(Input::default(), ®istry, &mut scripts) + .unwrap(); + assert_eq!(world.mod_sounds.len(), 1); + let id = &world.mod_sounds[0]; + let mut bank = sound::SoundBank::default(); + let mut audible = mixer(); + bank.queue_mod(®istry, &mut audible, id).unwrap(); + let mut pcm = [0; 1024]; + audible.mix(&mut pcm); + assert!(pcm.iter().any(|s| *s != 0)); + std::fs::write(&path, wav(1, 8, &vec![128; 2000])).unwrap(); + assert!( + bank.mod_sample(®istry, id) + .unwrap() + .data + .iter() + .any(|v| *v != 128) + ); + bank.clear_mods(); + let mut replaced = mixer(); + bank.queue_mod(®istry, &mut replaced, id).unwrap(); + replaced.mix(&mut pcm); + assert!(pcm.iter().all(|s| *s == 0)); +} +#[test] +fn package_local_sounds_validate_and_missing_files_reject_activation() { + let dir = tempfile::tempdir().unwrap(); + let package = dir.path().join("tone"); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write( + package.join("mod.toml"), + "id='tone'\nname='Tone'\nversion='1.0.0'\napi_version=1\ncontent='content.json'\n", + ) + .unwrap(); + std::fs::write( + package.join("content.json"), + serde_json::to_vec(&json!({"sounds":[{"id":"tone:shot","name":"Shot","file":"shot.wav"}]})) + .unwrap(), + ) + .unwrap(); + std::fs::write(package.join("shot.wav"), wav(1, 8, &[128, 200, 50])).unwrap(); + let found = modding::discover(dir.path()); + assert!(ActiveMods::build(&Registry::default(), &found, &["tone".into()], None).is_ok()); + std::fs::remove_file(package.join("shot.wav")).unwrap(); + let found = modding::discover(dir.path()); + assert!(ActiveMods::build(&Registry::default(), &found, &["tone".into()], None).is_err()); +} diff --git a/tests/transitions.rs b/RustRaptor/tests/transitions.rs similarity index 100% rename from tests/transitions.rs rename to RustRaptor/tests/transitions.rs diff --git a/tests/visuals.rs b/RustRaptor/tests/visuals.rs similarity index 99% rename from tests/visuals.rs rename to RustRaptor/tests/visuals.rs index f46b10c3..1140b4ad 100644 --- a/tests/visuals.rs +++ b/RustRaptor/tests/visuals.rs @@ -550,12 +550,12 @@ fn sheet(sprites: &[Sprite], columns: usize, palette: &[[u8; 3]; 256], path: &Pa #[ignore = "requires local original palette and sprites; emits private shadow contacts"] fn original_shadow_tables_and_banking_masks_match_indexed_projection() { let root = Path::new(env!("CARGO_MANIFEST_DIR")); - let catalog = Catalog::open(root.join("data")).unwrap(); + let catalog = Catalog::open(root.join("../data")).unwrap(); let palette = catalog.palette().unwrap(); let bytes = catalog.named("PALETTE_DAT").unwrap(); let vga: [[u8; 3]; 256] = std::array::from_fn(|i| bytes[i * 3..i * 3 + 3].try_into().unwrap()); assert_eq!(palette.map(|rgb| rgb.map(|component| component >> 2)), vga); - let output = root.join(".reference/visuals"); + let output = root.join("../.reference/visuals"); fs::create_dir_all(&output).unwrap(); // Independent argmin over (distance, reverse index) preserves GFX_Remap's // last-equal tie rule without the renderer's imperative running minimum. @@ -643,9 +643,9 @@ fn original_shadow_tables_and_banking_masks_match_indexed_projection() { #[ignore = "requires local original catalog; emits private .reference/visuals contact sheets"] fn original_sequences_preserve_canvases_palette_origins_and_terrain_context() { let root = Path::new(env!("CARGO_MANIFEST_DIR")); - let catalog = Catalog::open(root.join("data")).unwrap(); + let catalog = Catalog::open(root.join("../data")).unwrap(); let palette = catalog.palette().unwrap(); - let output = root.join(".reference/visuals"); + let output = root.join("../.reference/visuals"); fs::create_dir_all(&output).unwrap(); let sequences = [ ("LPLAYER_PIC", 7), diff --git a/RustRaptor/tests/weapon_balance.rs b/RustRaptor/tests/weapon_balance.rs new file mode 100644 index 00000000..f68a4c06 --- /dev/null +++ b/RustRaptor/tests/weapon_balance.rs @@ -0,0 +1,225 @@ +//! Local balance observations against original weapons in the real registry. +//! Targets are fixtures; no original artwork or data is required by CI. +use raptor_replica::{ + assets::Catalog, + content::{ContentId, ContentPack, Registry}, + modding::{self, ActiveMods}, + persistence::Pilot, + simulation::{Input, Outcome, World}, +}; +use serde_json::{Value, json}; +use std::{fs, path::Path}; + +const COMBAT_MODS: [&str; 6] = [ + "pulse_cannon", + "seeker_swarm", + "ion_lance", + "iron_coast_scout", + "iron_coast_turret", + "iron_coast_boss", +]; +const TICKS: usize = 700; +const HEALTH: i32 = 1_000_000; +const WEAPONS: [&str; 15] = [ + "base:forward_guns", + "base:plasma_guns", + "base:micro_missile", + "base:air_missile", + "base:grd_missile", + "base:dumb_missile", + "base:mini_gun", + "base:missile_pods", + "base:turret", + "base:pulse_cannon", + "base:death_ray", + "base:forward_laser", + "pulse_cannon:equipment", + "seeker_swarm:equipment", + "ion_lance:equipment", +]; +fn fixture(active: &mut ActiveMods) { + let pack: ContentPack = serde_json::from_value(json!({ + "missions":[{"id":"balance:range","name":"Weapon range","duration":10000,"auto_complete":false}], + "enemies":[ + {"id":"balance:air","name":"Air target","health":HEALTH,"speed":0}, + {"id":"balance:ground","name":"Ground target","health":HEALTH,"speed":0,"ground":true,"script":"ground.rhai"} + ] + })).unwrap(); + active.registry.apply(pack, "balance").unwrap(); + active + .scripts + .add( + "balance", + "ground.rhai", + r#" + // Ground actors normally scroll one logical pixel each tick. Reset + // this range target after collision processing; weapons are unmodified. + fn on_tick(event,state,random) { + #{commands:[#{op:"move",entity:event.entity,x:event.x,y:event.y-1}],state:state} + } + "#, + ) + .unwrap(); +} +fn observe( + active: &mut ActiveMods, + item: &str, + scene: &str, + targets: &[(&str, i32, i32)], + with_forward_guns: bool, +) -> Value { + active + .scripts + .restore((Default::default(), Default::default())); + let id = ContentId::from(item); + let mut inventory = [(id.clone(), 1), ("base:energy".into(), 100)] + .into_iter() + .collect::>(); + if with_forward_guns { + inventory.insert("base:forward_guns".into(), 1); + } + let pilot = Pilot { + mission: "balance:range".into(), + difficulty: 2, + inventory, + selected: active.registry.items[&id].special.then_some(id.clone()), + ..Pilot::default() + }; + let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap(); + let entities: Vec<_> = targets + .iter() + .map(|(kind, x, y)| { + world + .spawn( + &format!("balance:{kind}").as_str().into(), + *x, + *y, + &active.registry, + ) + .unwrap() + }) + .collect(); + for _ in 0..TICKS { + world + .step( + Input { + fire: true, + ..Input::default() + }, + &active.registry, + &mut active.scripts, + ) + .unwrap(); + } + assert_eq!(world.outcome, Outcome::Playing); + assert_eq!( + world.player.shield, 100, + "range targets should not harm the pilot" + ); + let damage: Vec<_> = entities + .iter() + .map(|entity| { + let target = world + .enemies + .iter() + .find(|e| e.entity == *entity) + .expect("range target retired"); + assert!(target.health > 0); + assert_eq!( + target.y, + targets[entities.iter().position(|e| e == entity).unwrap()].2 + ); + HEALTH - target.health + }) + .collect(); + json!({"item":item,"scene":scene,"price":active.registry.items[&id].price, + "ticks":TICKS,"seconds":TICKS as f64 * 3.0/70.0,"with_forward_guns":with_forward_guns, + "damage":damage,"damage_per_second":damage.iter().map(|d| *d as f64/(TICKS as f64*3.0/70.0)).collect::>()}) +} + +#[test] +#[ignore = "requires original untracked catalog; reports controlled stationary-target balance, not campaign difficulty"] +fn original_and_mod_weapons_report_controlled_damage_locally() { + let package = Path::new(env!("CARGO_MANIFEST_DIR")); + let root = std::env::var_os("RAPTOR_TEST_DATA") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| package.join("../data")); + let catalog = Catalog::open(root).unwrap(); + let base = Registry::base(&catalog).unwrap(); + let discovery = modding::discover(&package.join("mods")); + let enabled = COMBAT_MODS.map(String::from); + let mut active = ActiveMods::build(&base, &discovery, &enabled, Some(&catalog)).unwrap(); + fixture(&mut active); + let mut rows = Vec::new(); + for item in WEAPONS { + for (scene, targets) in [ + ("aligned_air", vec![("air", 144, 80)]), + ("aligned_ground", vec![("ground", 144, 80)]), + ("off_axis_air", vec![("air", 220, 80)]), + ("off_axis_ground", vec![("ground", 220, 80)]), + ("column", vec![("air", 144, 96), ("ground", 144, 40)]), + ( + "cluster", + vec![("air", 144, 80), ("ground", 144, 80), ("air", 154, 80)], + ), + ] { + let result = observe(&mut active, item, scene, &targets, false); + println!("{result}"); + rows.push(result); + } + } + for item in [ + "base:plasma_guns", + "base:micro_missile", + "pulse_cannon:equipment", + ] { + rows.push(observe( + &mut active, + item, + "aligned_air", + &[("air", 144, 80)], + true, + )); + rows.push(observe( + &mut active, + item, + "aligned_ground", + &[("ground", 144, 80)], + true, + )); + } + let report = json!({"difficulty":2,"rate":"70/3 Hz","target_size":[32,32], + "target_health":HEALTH,"fire_steps":TICKS,"rows":rows}); + if let Some(path) = std::env::var_os("RAPTOR_BALANCE_REPORT") { + fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap(); + } + let row = |item: &str, scene: &str| { + report["rows"] + .as_array() + .unwrap() + .iter() + .find(|r| r["item"] == item && r["scene"] == scene && r["with_forward_guns"] == false) + .unwrap() + }; + assert!( + row("seeker_swarm:equipment", "off_axis_air")["damage"][0] + .as_i64() + .unwrap() + > 0 + ); + assert!( + row("seeker_swarm:equipment", "off_axis_ground")["damage"][0] + .as_i64() + .unwrap() + > 0 + ); + let column = row("ion_lance:equipment", "column")["damage"] + .as_array() + .unwrap(); + assert!(column.iter().all(|d| d.as_i64().unwrap() > 0)); + assert_eq!( + column[0], column[1], + "ion beam should damage both column targets equally" + ); + assert_eq!(row("ion_lance:equipment", "off_axis_air")["damage"][0], 0); +} diff --git a/tests/weapons_mods.rs b/RustRaptor/tests/weapons_mods.rs similarity index 90% rename from tests/weapons_mods.rs rename to RustRaptor/tests/weapons_mods.rs index 8b5599cf..79fe32e2 100644 --- a/tests/weapons_mods.rs +++ b/RustRaptor/tests/weapons_mods.rs @@ -42,9 +42,7 @@ fn wav() -> Vec { bytes } fn install_synthetic(root: &Path, id: &str) { - let source = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("examples/mods") - .join(id); + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("mods").join(id); let target = root.join(id); fs::create_dir_all(target.join("scripts")).unwrap(); fs::create_dir_all(target.join("art")).unwrap(); @@ -184,7 +182,44 @@ fn pulse_cannon_emits_paired_rounds_and_every_third_burst_is_scripted() { assert_eq!(world.projectiles.len(), 8); assert_eq!(world.projectiles.iter().filter(|p| p.vx != 0).count(), 2); assert_eq!(world.mod_sounds, vec![ContentId::from("pulse_cannon:fire")]); - assert!(!world.effects.is_empty()); + assert!( + world.effects.is_empty(), + "firing into empty space must not create muzzle explosions" + ); +} + +#[test] +fn continuous_firing_has_audio_and_projectiles_without_explosions_at_the_muzzle() { + for id in PACKAGES { + let (_root, mut active) = activate(&[id]); + let mut world = world(id, &active.registry); + let mut heard_fire = false; + let mut saw_projectile = false; + for _ in 0..80 { + world + .step( + Input { + fire: true, + ..Input::default() + }, + &active.registry, + &mut active.scripts, + ) + .unwrap(); + heard_fire |= world + .mod_sounds + .contains(&format!("{id}:fire").as_str().into()); + saw_projectile |= !world.projectiles.is_empty(); + assert!( + world.effects.is_empty(), + "{id} created an explosion while firing into empty space" + ); + } + assert!( + heard_fire && saw_projectile, + "{id} lost its firing sound or projectiles" + ); + } } #[test] diff --git a/tools/campaign_check.rs b/RustRaptor/tools/campaign_check.rs similarity index 100% rename from tools/campaign_check.rs rename to RustRaptor/tools/campaign_check.rs diff --git a/tools/compare_replays.py b/RustRaptor/tools/compare_replays.py similarity index 100% rename from tools/compare_replays.py rename to RustRaptor/tools/compare_replays.py diff --git a/tools/generate_legacy_tables.py b/RustRaptor/tools/generate_legacy_tables.py similarity index 97% rename from tools/generate_legacy_tables.py rename to RustRaptor/tools/generate_legacy_tables.py index 9faea908..8ad2cd25 100644 --- a/tools/generate_legacy_tables.py +++ b/RustRaptor/tools/generate_legacy_tables.py @@ -4,7 +4,8 @@ This development tool is not used by the Rust game at build or runtime. """ from pathlib import Path import re -ROOT=Path(__file__).resolve().parents[1] +PACKAGE=Path(__file__).resolve().parents[1] +ROOT=PACKAGE.parent ids={k:int(v,16) for k,v in re.findall(r'#define\s+(FILE\w+)\s+(0x[0-9a-fA-F]+)',(ROOT/'raptor/src/fileids.h').read_text())} enum=(ROOT/'raptor/src/objects.h').read_text().split('S_FORWARD_GUNS',1)[1].split('}OBJ_TYPE')[0] names=['S_FORWARD_GUNS']+re.findall(r'^\s*(S_\w+)\s*[,\n]',enum,re.M) @@ -69,5 +70,5 @@ for name,rust in [('frequency_curve','FREQUENCY_CURVE'),('volume_mapping_table', block=re.sub(r'//[^\n]*','',block) nums=[int(x.strip(),0) for x in block.split(',') if x.strip()] out.append(f'pub const {rust}: [u16; {len(nums)}] = ['+', '.join(map(str,nums))+'];') -(ROOT/'src/legacy_tables.rs').write_text('\n'.join(out)+'\n') +(PACKAGE/'src/legacy_tables.rs').write_text('\n'.join(out)+'\n') print(f'Generated {len(names)} items and {len(weapons)} weapons') diff --git a/tools/hd_art_source.py b/RustRaptor/tools/hd_art_source.py similarity index 96% rename from tools/hd_art_source.py rename to RustRaptor/tools/hd_art_source.py index fb795649..b062718a 100644 --- a/tools/hd_art_source.py +++ b/RustRaptor/tools/hd_art_source.py @@ -37,7 +37,7 @@ def decode(path, palette): def main(): p = argparse.ArgumentParser(description=__doc__) - p.add_argument('--data', type=Path, default=Path('data')) + p.add_argument('--data', type=Path, default=Path(__file__).resolve().parents[2] / 'data') p.add_argument('--asset', required=True, type=lambda s: int(s, 16)) p.add_argument('--output', type=Path, required=True) p.add_argument('--preview-scale', type=int, default=1) diff --git a/docs/README.md b/docs/README.md index f980602c..3b769550 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,9 +1,10 @@ # RaptorReplica -The retained C++ engine described below is the playable reference. An independent -Rust rewrite is now selectable with `./play.sh --engine rust`; see -[Rust implementation and validation](rust-rewrite.md), [modding API](modding-api.md) -and [optional HD art](hd-art.md). Full Rust cutover acceptance remains in progress. +RustRaptor is the default game in `../RustRaptor/`; see +[Rust implementation and validation](../RustRaptor/docs/rust-rewrite.md), +[modding API](../RustRaptor/docs/modding-api.md) and +[optional HD art](../RustRaptor/docs/hd-art.md). +The historical C++ reference is described below. A faithful, playable replica of **Raptor: Call of the Shadows** (Cygnus Studios, 1994) running the reconstructed original engine on Linux, with the @@ -12,7 +13,7 @@ original game data extracted into a modern, editable asset tree. ## Quick start ```sh -./play.sh +./play.sh --engine legacy ``` Builds the engine on first run, then launches fullscreen. Saves and diff --git a/docs/building.md b/docs/building.md index 7035cb45..55a558cd 100644 --- a/docs/building.md +++ b/docs/building.md @@ -1,9 +1,9 @@ # Building and running -For the independent Rust engine, use `./play.sh --engine rust`. Rust 1.88+, +For RustRaptor, use `./play.sh` or `./RustRaptor/run.sh`. Rust 1.88+, Cargo and SDL2 development libraries are required. The launcher builds in release mode and sets the local SDL2 library path. See [Rust build/test commands and -separate profile locations](rust-rewrite.md). The sections below describe the +separate profile locations](../RustRaptor/docs/rust-rewrite.md). The sections below describe the retained C++ engine; its launcher now rebuilds the target before each launch. ## Requirements @@ -18,7 +18,7 @@ retained C++ engine; its launcher now rebuilds the target before each launch. ## Build and run ```sh -./play.sh +./play.sh --engine legacy ``` `play.sh` checks `data/INDEX`, builds `raptor/build/bin/raptor` on first run diff --git a/docs/rust-rewrite.md b/docs/rust-rewrite.md deleted file mode 100644 index c4ba43cb..00000000 --- a/docs/rust-rewrite.md +++ /dev/null @@ -1,85 +0,0 @@ -# Rust engine and validation - -The Rust binary never calls or embeds the C++ engine. SDL2 provides window/input/controller/audio services. Simulation and the validation CLI build without SDL2. The C++ checkout stays available for controlled reference comparisons and normal play until full acceptance passes. - -| Module | Responsibility | -| --- | --- | -| assets | Checked catalog, palettes, raw/span pictures, libraries, maps and demo records. | -| content, legacy_tables | Typed registries and generated original numeric mappings. | -| simulation | Rational clock, integer motion, shared RNG, collision/weapon/enemy/terrain rules, script transactions. | -| render, hd | Indexed canvas, ScaleNx, caches, PNG/palette conversion and optional source-hashed HD art. | -| audio, sound | Original MUS/GENMIDI FM and digital DSP behavior; package-local PCM WAV decoding. | -| cinematics | Checked AGX delta frames and movie timing. | -| persistence | TOML settings, versioned JSON pilots, atomic writes and read-only legacy import. | -| modding | Packages, dependency ordering, overrides, bounded Rhai callbacks/state and save pins. | -| desktop | SDL2 services, UI, shop/equipment, mod manager, settings and interpolation. | - -## Build and run - -Rust 1.88+, Cargo and SDL2 development libraries are required. A local SDL2 installation in `~/.local/lib` is supported. The launcher sets the library path and uses a release build: - -```sh -./play.sh --engine rust -./play.sh --engine rust --mods examples/mods -``` - -For development and tests: - -```sh -export LD_LIBRARY_PATH="$HOME/.local/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" -cargo test --locked -cargo test --locked --no-default-features -RAPTOR_TEST_DATA="$PWD/data" cargo test --locked -RAPTOR_TEST_DATA="$PWD/data" cargo test --locked -- --ignored -``` - -Synthetic fixtures contain no copyrighted or generated game pixels. Original integration checks are opt-in and local. Native audio comparisons compile a small retained-source harness with g++ and SDL2 headers; pure Rust playback itself uses no C++ runtime. - -## Profiles and settings - -Rust uses `$XDG_DATA_HOME/RaptorReplica/pilot-NNNN.json` and `mods/`, `$XDG_CONFIG_HOME/RaptorReplica/config.toml`, and `$XDG_CACHE_HOME/RaptorReplica/` for derived assets. Normal XDG defaults apply. Pilots record active package versions/hashes and script state/RNG. Missing or changed requirements block loading, without deleting equipment or replacing the save. - -Legacy `~/.local/share/Raptor/SETUP.INI` and pilot files are imported read-only. All difficulties, waves, portrait and training metadata are retained. Rust writes only its separate versioned files. Explicit CLI import output cannot be the original file. - -Options include scaling, scanlines, aspect correction, fullscreen/borderless, input bindings/controller, volumes and optional HD art. New/load pilot controls keep incompatible save slots separate. The Mods screen applies validated selections at the main menu; selections made during flight remain pending until returning there. - -## Local asset and platform checks - -```sh -cargo run --locked --no-default-features -- --data data --verify-assets -cargo run --locked --no-default-features -- --data data --mods examples/mods --validate-mods aurora -cargo run --locked --no-default-features -- --data data --mods examples/mods \ - --enabled aurora --headless aurora:field_trials --ticks 700 -SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy cargo run --locked -- \ - --data data --profile-root .reference/smoke --smoke-frames 5 \ - --screenshot .reference/menu.png -``` - -Use real SDL video for interactive/display-performance checks. Dummy screenshots verify raster placement, not GPU/vsync performance. `--preview-mission ID` starts a mission; `--preview-cinematic intro|landing|death|end1|end2|end3` previews movies. Frame-limited runs do not save pilots/settings; cache stays within the selected profile. - -## Controlled reference replays - -Rebuild the current C++ source before recording. Trace output is opt-in during legacy demo playback and records complete updates after palette/stat processing, which also consumes the shared RNG. - -```sh -mkdir -p .reference -cmake --build raptor/build --target raptor --parallel 8 -(cd data && env SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \ - XDG_DATA_HOME="$PWD/../.reference/legacy-profile" \ - RAPTOR_REFERENCE_TRACE="$PWD/../.reference/cpp-demo.jsonl" \ - RAPTOR_REFERENCE_TICKS=1000 \ - ../raptor/build/bin/raptor PLAY "$PWD/demos/DEMO1G1_REC") -cargo run --locked --no-default-features -- --data data \ - --replay DEMO1G1_REC --ticks 1000 --trace .reference/rust-demo.jsonl -python3 tools/compare_replays.py .reference/cpp-demo.jsonl .reference/rust-demo.jsonl -``` - -The first 1,000 steps of DEMO1G1_REC have matched all captured fields: RNG, player coordinates/bank/shields/credits, ordered enemy position/health/animation/path state and ordered projectile assets/positions. The comparator fails at first divergence or unequal lengths. This is a subset of full state: add inventory, effects, objectives and all internal AI state before declaring complete parity. That demo starts sector 1 wave 3, so it does not validate the first mission or every campaign. Recordings stay ignored in `.reference/`. - -## Audio and remaining acceptance - -The Rust FM/DSP implementation has synthetic sample-by-sample native comparisons and a four-second original intro-music comparison at 44.1kHz. These establish those cases, not every track/input/output rate. The Rust core retains the original envelope/noise semantics and LGPL source provenance. - -Longer replays, all campaign/difficulty paths and end-to-end boss playthroughs still require verification. Original cinematics decode/play locally but exact fade/transition/custom-palette fidelity needs comparison. Some menu layouts differ. HD coverage is partial and display performance must be measured before cutover. - -Scale2x/3x follows the [published algorithm](https://www.scale2x.it/algorithm); Scale4x applies Scale2x twice. WAV parsing follows the [RIFF chunk format](https://learn.microsoft.com/en-us/windows/win32/xaudio2/resource-interchange-file-format--riff-). Original catalog/reference assets remain outside version control. Authored mod artwork and sounds are tracked. diff --git a/play.sh b/play.sh index f35fa821..fe8f7daa 100755 --- a/play.sh +++ b/play.sh @@ -1,9 +1,9 @@ #!/bin/sh -# Keep the reference engine as the default until Rust parity is validated. +# RustRaptor is the default; the original engine is an optional reference. set -eu cd "$(dirname "$0")" ROOT="$PWD" -ENGINE=legacy +ENGINE=rust if [ "${1:-}" = --engine ]; then [ "$#" -ge 2 ] || { echo 'Usage: ./play.sh --engine legacy|rust [arguments]' >&2; exit 2; } ENGINE="$2" @@ -12,7 +12,7 @@ fi export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$HOME/.local/lib" case "$ENGINE" in rust) - exec cargo run --locked --release --manifest-path "$ROOT/Cargo.toml" --bin raptor-rust -- --data "$ROOT/data" "$@" + exec cargo run --locked --release --manifest-path "$ROOT/RustRaptor/Cargo.toml" --bin raptor-rust -- "$@" ;; legacy) if [ ! -f data/INDEX ]; then diff --git a/src/desktop.rs b/src/desktop.rs deleted file mode 100644 index 8300799b..00000000 --- a/src/desktop.rs +++ /dev/null @@ -1,2147 +0,0 @@ -//! SDL host, interpolated scene presentation and local game menus. -use crate::{ - assets::{Catalog, Sprite}, - audio::{DigitalSample, Mixer, MusScore}, - cinematics::{Cinematic, Kind as MovieKind}, - content::{Art, ContentId, Registry, art_sprite_variant}, - hd::HdAssets, - modding::{self, ActiveMods, Discovery}, - persistence::{Config, Paths, Pilot}, - render::{ - BitmapFont, Canvas, SpriteCache, legacy_light_table, nearest, prefers_nearest, - shade_air_shadow, shade_ground_shadow, upscale, - }, - simulation::{Clock, Input, Outcome, World}, -}; -use anyhow::{Context, Result, anyhow}; -use sdl2::{ - audio::{AudioCallback, AudioDevice, AudioSpecDesired}, - controller::{Axis, Button}, - event::Event, - keyboard::{Keycode, Scancode}, - pixels::PixelFormatEnum, - rect::Rect, - video::FullscreenType, -}; -use std::{ - collections::BTreeMap, - path::PathBuf, - time::{Duration, Instant}, -}; - -impl AudioCallback for Mixer { - type Channel = i16; - fn callback(&mut self, out: &mut [i16]) { - self.mix(out) - } -} -#[derive(Clone, Copy, PartialEq, Eq)] -enum Screen { - Main, - Missions, - Shop, - Inventory, - Mods, - Options, - Game, - Pause, - Results, - Help, - Credits, - Pilots, - Diagnostics, - Bindings, - Cinematic, -} -struct Host { - catalog: Catalog, - base: Registry, - active: ActiveMods, - paths: Paths, - mods_root: PathBuf, - discovery: Discovery, - config: Config, - hd: HdAssets, - read_only: bool, - pending: Vec, - pilot: Pilot, - slot: usize, - pilot_error: Option, - queued_cycle: bool, - queued_bomb: bool, - awaiting_binding: Option, - world: Option, - cinematic: Option, - movie_return: Screen, - screen: Screen, - previous: Screen, - selected: usize, - message: String, - font: BitmapFont, - cache: SpriteCache, - raw: BTreeMap, - mod_art: BTreeMap, - palette: [[u8; 3]; 256], - shadow_table: [u8; 256], - air_shadow_table: [u8; 256], - audio: Option>, - mod_audio: BTreeMap, - desired_music: Option, - clock: Clock, -} -impl Host { - fn new( - catalog: Catalog, - base: Registry, - paths: Paths, - mods_root: PathBuf, - read_only: bool, - ) -> Result { - let config = Config::load(&paths)?; - let discovery = modding::discover(&mods_root); - let mut message = String::new(); - let mut active = - match ActiveMods::build(&base, &discovery, &config.enabled_mods, Some(&catalog)) { - Ok(a) => a, - Err(e) => { - message = format!("Mod load failed: {e:#}"); - ActiveMods::build(&base, &discovery, &[], Some(&catalog))? - } - }; - let mut pilot_error = None; - let mut pilot = if paths.save_path(0).exists() { - match Pilot::load(&paths.save_path(0), &active.registry, &active.pins) { - Ok(p) => p, - Err(e) => { - message = format!("Saved pilot unavailable: {e:#}"); - pilot_error = Some(message.clone()); - Pilot::default() - } - } - } else { - let legacy = paths.legacy.join("CHAR0000.FIL"); - if legacy.exists() { - match Pilot::import_legacy(&legacy, &active.registry) { - Ok(p) => p, - Err(e) => { - message = format!("Legacy import failed: {e:#}"); - Pilot::default() - } - } - } else { - Pilot::default() - } - }; - if !paths.save_path(0).exists() { - pilot.mods = active.pins.clone(); - } - restore_scripts(&mut active.scripts, &pilot); - let hd = HdAssets::load(&catalog.root.join("hd"), &catalog); - let palette = catalog.palette()?; - let vga_palette = palette.map(|color| color.map(|component| component >> 2)); - let shadow_table = legacy_light_table(&vga_palette, -9); - let air_shadow_table = legacy_light_table(&vga_palette, -6); - let font = BitmapFont::decode(&catalog.named("SYSTEM_FNT")?)?; - let cache = SpriteCache::new(Some(paths.cache.join("sprites"))); - let pending = config.enabled_mods.clone(); - Ok(Self { - catalog, - base, - active, - paths, - mods_root, - discovery, - config, - hd, - read_only, - pending, - pilot, - slot: 0, - pilot_error, - queued_cycle: false, - queued_bomb: false, - awaiting_binding: None, - world: None, - cinematic: None, - movie_return: Screen::Main, - screen: Screen::Main, - previous: Screen::Main, - selected: 0, - message, - font, - cache, - raw: BTreeMap::new(), - mod_art: BTreeMap::new(), - palette, - shadow_table, - air_shadow_table, - audio: None, - mod_audio: BTreeMap::new(), - desired_music: None, - clock: Clock::default(), - }) - } - fn go(&mut self, screen: Screen) { - self.screen = screen; - self.selected = 0; - } - fn save_config(&self) -> Result<()> { - if self.read_only { - return Ok(()); - } - self.config.save(&self.paths) - } - fn save(&mut self) -> Result<()> { - if self.read_only { - return Ok(()); - } - self.require_pilot()?; - self.pilot.script_state = self.active.scripts.states.clone(); - self.pilot.script_rng = self.active.scripts.rng.clone(); - self.pilot - .validate(&self.active.registry, &self.active.pins)?; - self.pilot.mods = self.active.pins.clone(); - self.pilot.save(&self.paths.save_path(self.slot))?; - self.save_config() - } - fn music(&mut self, name: &str) { - self.desired_music = self.catalog.id(name).ok(); - let score = self.catalog.named(name).and_then(|b| MusScore::decode(&b)); - if let (Some(device), Ok(score)) = (&mut self.audio, score) { - device.lock().play_with_fade(score, true, true); - } - } - fn start(&mut self, id: ContentId) -> Result<()> { - self.require_pilot()?; - self.pilot.mission = id.clone(); - self.pilot.difficulty = self.config.difficulty; - self.active.scripts.states = self.pilot.script_state.clone(); - self.active.scripts.rng = self.pilot.script_rng.clone(); - self.world = Some(World::new(&id, &self.pilot, &self.active.registry)?); - if let Some(music) = self.active.registry.missions[&id].music { - self.desired_music = Some(music); - if let Ok(score) = self.catalog.read(music).and_then(|b| MusScore::decode(&b)) { - if let Some(audio) = &mut self.audio { - audio.lock().play_with_fade(score, true, true); - } - } - } - self.clock = Clock::default(); - self.go(Screen::Game); - Ok(()) - } - fn show_movie(&mut self, kind: MovieKind, return_to: Screen) -> Result<()> { - let movie = Cinematic::load(&self.catalog, kind)?; - self.cinematic = Some(movie); - self.movie_return = return_to; - match kind { - MovieKind::Intro => self.music("RINTRO_MUS"), - MovieKind::Death => self.music("RAP5_MUS"), - _ => {} - } - self.go(Screen::Cinematic); - Ok(()) - } - fn finish_movie(&mut self) { - self.cinematic = None; - self.go(self.movie_return); - if self.screen == Screen::Main { - self.music("MAINMENU_MUS"); - } - } - fn items(&self) -> Vec { - self.active - .registry - .items - .values() - .filter(|i| i.legacy.is_none_or(|n| n < 18)) - .map(|i| i.id.clone()) - .collect() - } - fn missions(&self) -> Vec { - let mut ids: Vec<_> = self - .active - .registry - .campaigns - .values() - .flat_map(|c| c.missions.clone()) - .collect(); - let mut seen = std::collections::BTreeSet::new(); - ids.retain(|id| seen.insert(id.clone())); - for id in self.active.registry.missions.keys() { - if seen.insert(id.clone()) { - ids.push(id.clone()); - } - } - ids - } - fn rows(&self) -> Vec { - match self.screen { - Screen::Main => vec![ - "Continue / Hangar", - "Select Mission", - "Shop", - "Inventory", - "Mods", - "Options", - "Load Pilot", - "Help", - "Credits", - "Watch intro", - "Quit", - ] - .into_iter() - .map(str::to_string) - .collect(), - Screen::Missions => self - .missions() - .iter() - .map(|id| { - format!( - "{}{}", - if self.pilot.completed.contains(id) { - "+ " - } else { - " " - }, - self.active.registry.missions[id].name - ) - }) - .collect(), - Screen::Shop => self - .items() - .iter() - .map(|id| { - let i = &self.active.registry.items[id]; - format!("{} ${}", i.name, i.price) - }) - .collect(), - Screen::Inventory => self - .pilot - .inventory - .iter() - .filter_map(|(id, n)| { - self.active.registry.items.get(id).map(|i| { - format!( - "{} x{}{}", - i.name, - n, - if self.pilot.selected.as_ref() == Some(id) { - " [EQUIPPED]" - } else { - "" - } - ) - }) - }) - .collect(), - Screen::Pilots => self - .slots() - .iter() - .map(|n| format!("Pilot slot {:04}", n)) - .collect(), - Screen::Diagnostics => self.diagnostics(), - Screen::Bindings => ACTIONS - .iter() - .map(|action| { - format!( - "{}: {}", - action, - self.config - .bindings - .get(*action) - .map(String::as_str) - .unwrap_or("unbound") - ) - }) - .chain(std::iter::once("Restore default bindings".into())) - .collect(), - Screen::Mods => { - let mut r = vec!["Apply selections".into(), "Rescan installed mods".into()]; - let mut ids = self.pending.clone(); - for id in self.discovery.mods.keys() { - if !ids.contains(id) { - ids.push(id.clone()); - } - } - for id in ids { - if let Some(m) = self.discovery.mods.get(&id) { - r.push(format!( - "[{}] {} {}", - if self.pending.contains(&id) { "X" } else { " " }, - m.manifest.name, - m.manifest.version - )); - } - } - r - } - Screen::Options => vec![ - if self.config.scale == 1 { - "Sprite scale: original".into() - } else { - format!("Sprite scale: {}x crisp", self.config.scale) - }, - format!("Scanlines: {}", self.config.scanlines), - format!("Fullscreen: {}", self.config.fullscreen), - format!("Borderless: {}", self.config.borderless), - format!("4:3 aspect: {}", self.config.aspect_correct), - format!("Music volume: {}", self.config.music_volume), - format!("Effects volume: {}", self.config.fx_volume), - format!("Controls: {}", self.config.control), - format!( - "Difficulty: {}", - ["Training", "Rookie", "Veteran", "Elite"][self.config.difficulty as usize] - ), - format!("Resolution: {}x{}", self.config.width, self.config.height), - format!( - "HD art: {} ({} frames)", - if self.config.hd_mode { "on" } else { "off" }, - self.hd.coverage() - ), - "Keyboard bindings".into(), - format!("Effects channels: {}", self.config.fx_channels), - ], - Screen::Pause => vec!["Resume", "Options", "Mods", "Return to main menu"] - .into_iter() - .map(str::to_string) - .collect(), - Screen::Results => vec!["Return to hangar".into()], - _ => vec![], - } - } - fn mod_ids(&self) -> Vec { - let mut ids = self - .pending - .iter() - .filter(|id| self.discovery.mods.contains_key(*id)) - .cloned() - .collect::>(); - for id in self.discovery.mods.keys() { - if !ids.contains(id) { - ids.push(id.clone()); - } - } - ids - } - fn require_pilot(&self) -> Result<()> { - if let Some(error) = &self.pilot_error { - return Err(anyhow!( - "{error}. Restore matching mods, load a compatible pilot, or press N to create a separate pilot." - )); - } - self.pilot - .validate(&self.active.registry, &self.active.pins) - } - fn slots(&self) -> Vec { - (0..1000) - .filter(|n| self.paths.save_path(*n).is_file()) - .collect() - } - fn new_pilot(&mut self) -> Result<()> { - let slot = (0..1000) - .find(|n| !self.paths.save_path(*n).exists()) - .context("all pilot slots are occupied")?; - self.slot = slot; - self.pilot = Pilot { - mods: self.active.pins.clone(), - ..Pilot::default() - }; - self.pilot_error = None; - self.active.scripts.states.clear(); - self.active.scripts.rng.clear(); - self.message = format!("New pilot in slot {slot:04}"); - Ok(()) - } - fn diagnostics(&self) -> Vec { - let mut lines = vec![self.message.clone()]; - if let Some(e) = &self.pilot_error { - lines.push(e.clone()); - } - lines.extend(self.discovery.errors.clone()); - lines.extend(self.hd.diagnostics.clone()); - lines.push(format!( - "HD art coverage: {} supplied frames; remaining artwork uses original sprites", - self.hd.coverage() - )); - lines.push(format!( - "Active order: {}", - self.active - .pins - .iter() - .map(|m| m.id.clone()) - .collect::>() - .join(", ") - )); - lines.extend(self.active.registry.overrides.clone()); - for m in self.discovery.mods.values() { - lines.push(format!( - "{} {}: {}", - m.manifest.id, m.manifest.version, m.manifest.description - )); - lines.push(format!( - "Dependencies: {:?}; conflicts: {:?}", - m.manifest.dependencies, m.manifest.conflicts - )); - } - lines - .into_iter() - .flat_map(|s| { - s.chars() - .collect::>() - .chunks(52) - .map(|c| c.iter().collect::()) - .collect::>() - }) - .collect() - } - fn apply_mods(&mut self) -> Result<()> { - let mut candidate = ActiveMods::build( - &self.base, - &self.discovery, - &self.pending, - Some(&self.catalog), - )?; - let mut config = self.config.clone(); - config.enabled_mods = self.pending.clone(); - if !self.read_only { - config.save(&self.paths)?; - } - let saved = self.paths.save_path(self.slot); - match pilot_for_mods(&saved, &self.pilot, &candidate.registry, &candidate.pins) { - Ok(pilot) => { - restore_scripts(&mut candidate.scripts, &pilot); - self.pilot = pilot; - self.pilot_error = None; - } - Err(e) => self.pilot_error = Some(format!("Pilot slot {:04}: {e:#}", self.slot)), - } - self.config = config; - self.active = candidate; - self.mod_audio.clear(); - self.mod_art.clear(); - if self.pilot_error.is_some() { - self.message = - "Mods applied; pilot requires its saved mods. D: details / N: new pilot".into(); - } else { - self.message = "Mods applied".into(); - } - Ok(()) - } - fn activate(&mut self) -> Result { - match self.screen { - Screen::Main => match self.selected { - 0 => self.start(self.pilot.mission.clone())?, - 1 => self.go(Screen::Missions), - 2 => { - self.go(Screen::Shop); - self.music("HANGAR_MUS"); - } - 3 => self.go(Screen::Inventory), - 4 => self.go(Screen::Mods), - 5 => { - self.previous = Screen::Main; - self.go(Screen::Options) - } - 6 => self.go(Screen::Pilots), - 7 => self.go(Screen::Help), - 8 => self.go(Screen::Credits), - 9 => self.show_movie(MovieKind::Intro, Screen::Main)?, - 10 => return Ok(false), - _ => {} - }, - Screen::Missions => { - if let Some(id) = self.missions().get(self.selected) { - self.start(id.clone())?; - } - } - Screen::Pilots => { - if let Some(slot) = self.slots().get(self.selected).copied() { - let p = Pilot::load( - &self.paths.save_path(slot), - &self.active.registry, - &self.active.pins, - )?; - restore_scripts(&mut self.active.scripts, &p); - self.pilot = p; - self.slot = slot; - self.pilot_error = None; - self.message = format!("Loaded pilot slot {slot:04}"); - self.go(Screen::Main); - } - } - Screen::Shop => { - self.require_pilot()?; - if let Some(id) = self.items().get(self.selected) { - self.pilot.buy(id, &self.active.registry)?; - self.save()?; - self.message = "Purchase complete".into(); - } - } - Screen::Inventory => { - self.require_pilot()?; - if let Some(id) = self.pilot.inventory.keys().nth(self.selected).cloned() { - if self - .active - .registry - .items - .get(&id) - .is_some_and(|i| i.special) - { - self.pilot.selected = Some(id); - self.save()?; - self.message = "Weapon equipped".into(); - } - } - } - Screen::Mods => { - if self.selected == 0 { - if self.world.is_some() { - self.config.enabled_mods = self.pending.clone(); - self.save_config()?; - self.message = "Changes pending until the main menu".into(); - } else { - self.apply_mods()?; - } - } else if self.selected == 1 { - self.discovery = modding::discover(&self.mods_root); - self.message = self.discovery.errors.join("; "); - } else if let Some(id) = self.mod_ids().get(self.selected - 2).cloned() { - if let Some(n) = self.pending.iter().position(|s| s == &id) { - self.pending.remove(n); - } else { - self.pending.push(id); - } - } - } - Screen::Options => match self.selected { - 0 => self.config.scale = self.config.scale % 4 + 1, - 1 => self.config.scanlines = !self.config.scanlines, - 2 => self.config.fullscreen = !self.config.fullscreen, - 3 => self.config.borderless = !self.config.borderless, - 4 => self.config.aspect_correct = !self.config.aspect_correct, - 5 => self.config.music_volume = (self.config.music_volume + 16) % 128, - 6 => self.config.fx_volume = (self.config.fx_volume + 16) % 128, - 7 => { - self.config.control = match self.config.control.as_str() { - "keyboard" => "mouse", - "mouse" => "controller", - _ => "keyboard", - } - .into() - } - 8 => self.config.difficulty = (self.config.difficulty + 1) % 4, - 9 => { - if self.config.width == 1280 { - self.config.width = 1920; - self.config.height = 1080 - } else { - self.config.width = 1280; - self.config.height = 960 - } - } - 10 => self.config.hd_mode = !self.config.hd_mode, - 11 => self.go(Screen::Bindings), - 12 => self.config.fx_channels = self.config.fx_channels % 8 + 1, - _ => {} - }, - Screen::Bindings => { - if let Some(action) = ACTIONS.get(self.selected) { - self.awaiting_binding = Some(action.to_string()); - self.message = format!("Press a key for {action}; ESC cancels"); - } else { - self.config.bindings = Config::default().bindings; - self.save_config()?; - self.message = "Default bindings restored".into(); - } - } - Screen::Pause => match self.selected { - 0 => self.go(Screen::Game), - 1 => { - self.previous = Screen::Pause; - self.go(Screen::Options) - } - 2 => self.go(Screen::Mods), - 3 => self.return_main()?, - _ => {} - }, - Screen::Results => self.return_main()?, - _ => {} - } - Ok(true) - } - fn return_main(&mut self) -> Result<()> { - self.world = None; - self.clock = Clock::default(); - if self.pending - != self - .active - .pins - .iter() - .map(|m| m.id.clone()) - .collect::>() - { - if let Err(e) = self.apply_mods() { - self.message = format!("Mod changes pending: {e:#}"); - } - } - self.go(Screen::Main); - self.music("MAINMENU_MUS"); - Ok(()) - } - fn back(&mut self) -> Result<()> { - match self.screen { - Screen::Cinematic => self.finish_movie(), - Screen::Game => self.go(Screen::Pause), - Screen::Pause => self.go(Screen::Game), - Screen::Options => { - self.save_config()?; - self.go(self.previous) - } - Screen::Mods if self.world.is_some() => self.go(Screen::Pause), - Screen::Diagnostics => self.go(self.previous), - Screen::Bindings => { - self.awaiting_binding = None; - self.save_config()?; - self.go(Screen::Options); - } - Screen::Results => self.return_main()?, - _ => self.go(Screen::Main), - } - Ok(()) - } - fn update(&mut self, input: Input, nanos: u64) -> Result<()> { - if self.screen == Screen::Cinematic { - let movie = self - .cinematic - .as_mut() - .context("missing active cinematic")?; - movie.advance(nanos)?; - let sounds = std::mem::take(&mut movie.sounds); - let finished = movie.finished(); - if let Some(audio) = &mut self.audio { - for event in sounds { - if let Ok(sample) = self - .catalog - .read(event.asset) - .and_then(|b| DigitalSample::decode(&b)) - { - audio.lock().sample( - sample, - event.pan, - event.pitch, - event.volume, - event.priority, - ); - } - } - } - if finished { - self.finish_movie(); - } - return Ok(()); - } - if self.screen != Screen::Game { - return Ok(()); - } - self.queued_cycle |= input.cycle; - self.queued_bomb |= input.bomb; - let steps = self.clock.advance(nanos); - let mut input = input; - input.cycle = self.queued_cycle; - input.bomb = self.queued_bomb; - for _ in 0..steps { - let w = self.world.as_mut().context("missing active mission")?; - w.step(input, &self.active.registry, &mut self.active.scripts)?; - input.cycle = false; - input.bomb = false; - self.queued_cycle = false; - self.queued_bomb = false; - if let Some(audio) = &mut self.audio { - for event in &w.sound_events { - if let Ok(sample) = self - .catalog - .read(event.asset) - .and_then(|b| DigitalSample::decode(&b)) - { - audio.lock().sample( - sample, - event.pan, - event.pitch, - event.volume, - event.priority, - ); - } - } - for id in &w.mod_sounds { - let definition = self - .active - .registry - .sounds - .get(id) - .context("missing mod sound")?; - if !self.mod_audio.contains_key(id) { - let owner = &self.active.registry.owners[id]; - let root = &self.active.registry.asset_roots[owner]; - let path = - crate::assets::contained(root, std::path::Path::new(&definition.file))?; - let sample = crate::sound::decode(&std::fs::read(path)?)?; - if self.mod_audio.values().map(|s| s.data.len()).sum::() - + sample.data.len() - > 64 * 1024 * 1024 - { - self.mod_audio.clear(); - } - self.mod_audio.insert(id.clone(), sample); - } - audio.lock().sample( - self.mod_audio[id].clone(), - definition.pan, - definition.pitch, - definition.volume, - definition.priority as i32, - ); - } - } - if w.outcome != Outcome::Playing { - let outcome = w.outcome; - let mission = w.mission.clone(); - let mut pilot = w.pilot(&self.pilot); - if outcome == Outcome::Complete { - pilot.complete_mission(&self.active.registry)?; - self.config.difficulty = pilot.difficulty; - self.pilot = pilot; - self.save()?; - self.message = "Mission complete".into(); - } else { - self.message = "Ship lost - previous hangar save retained".into(); - } - let kind = if outcome == Outcome::Failed { - MovieKind::Death - } else if mission.0.ends_with("_wave_9") { - let sector = mission - .0 - .strip_prefix("base:sector_") - .and_then(|s| s.split('_').next()) - .and_then(|s| s.parse::().ok()) - .unwrap_or(1); - MovieKind::CampaignEnd(sector.saturating_sub(1)) - } else { - MovieKind::Landing - }; - if mission.0.starts_with("base:") { - if let Err(e) = self.show_movie(kind, Screen::Results) { - self.message = format!("Cinematic unavailable: {e:#}"); - self.go(Screen::Results); - } - } else { - self.go(Screen::Results); - } - break; - } - } - Ok(()) - } - fn raw(&mut self, id: u32) -> Result { - if let Some(s) = self.raw.get(&id) { - return Ok(s.clone()); - } - let s = self.catalog.sprite(id)?; - self.raw.insert(id, s.clone()); - Ok(s) - } - fn art( - &mut self, - canvas: &mut Canvas, - art: &Art, - owner: &str, - frame: u32, - x: f32, - y: f32, - ) -> Result<()> { - let key = if art.png.is_some() || art.hd_png.is_some() { - Some(format!( - "{owner}:{}:{frame}:{}:{}", - serde_json::to_string(art)?, - canvas.scale, - self.config.hd_mode - )) - } else { - None - }; - if let Some(sprite) = key.as_ref().and_then(|k| self.mod_art.get(k)) { - canvas.blit( - sprite, - (x * canvas.scale as f32).round() as i32, - (y * canvas.scale as f32).round() as i32, - ); - return Ok(()); - } - if let Some(id) = art.asset { - if self.config.hd_mode && art.hd_png.is_none() { - if let Some(sprite) = - self.hd - .sprite(id + frame % art.frames, canvas.scale, &self.palette)? - { - if sprite.width == art.logical_width as usize * canvas.scale - && sprite.height == art.logical_height as usize * canvas.scale - { - canvas.blit( - &sprite, - (x * canvas.scale as f32).round() as i32, - (y * canvas.scale as f32).round() as i32, - ); - return Ok(()); - } - } - } - } - let sprite = if let Some(id) = art - .asset - .filter(|_| !(self.config.hd_mode && art.hd_png.is_some())) - { - self.raw(id + frame % art.frames)? - } else { - art_sprite_variant( - &self.catalog, - &self.active.registry, - owner, - art, - frame, - self.config.hd_mode, - )? - }; - let scaled = if sprite.width == art.logical_width as usize - && sprite.height == art.logical_height as usize - { - let pixelated = art.nearest - || art - .asset - .is_some_and(|id| prefers_nearest(id + frame % art.frames)); - self.cache.get(&sprite, canvas.scale, pixelated)?.clone() - } else { - let mut out = Sprite::blank( - art.logical_width as usize * canvas.scale, - art.logical_height as usize * canvas.scale, - ); - for yy in 0..out.height { - for xx in 0..out.width { - out.pixels[yy * out.width + xx] = - sprite.pixels[(yy * sprite.height / out.height) * sprite.width - + xx * sprite.width / out.width]; - } - } - out - }; - canvas.blit( - &scaled, - (x * canvas.scale as f32).round() as i32, - (y * canvas.scale as f32).round() as i32, - ); - if let Some(key) = key { - if self - .mod_art - .values() - .map(|s| s.pixels.len() * 2) - .sum::() - + scaled.pixels.len() * 2 - > 128 * 1024 * 1024 - { - self.mod_art.clear(); - } - self.mod_art.insert(key, scaled); - } - Ok(()) - } - fn shadow( - &mut self, - canvas: &mut Canvas, - art: &Art, - owner: &str, - frame: u32, - x: f32, - y: f32, - ground: bool, - ) -> Result<()> { - // Original masks retain their native canvas even when HD art is enabled. - // Mod masks use the supplied SD artwork and explicit logical dimensions. - let source = if let Some(asset) = art.asset { - self.raw(asset + frame % art.frames)? - } else { - art_sprite_variant( - &self.catalog, - &self.active.registry, - owner, - art, - frame, - false, - )? - }; - let mask = if (source.width, source.height) - == (art.logical_width as usize, art.logical_height as usize) - { - source - } else { - let mut logical = - Sprite::blank(art.logical_width as usize, art.logical_height as usize); - for row in 0..logical.height { - for column in 0..logical.width { - logical.pixels[row * logical.width + column] = - source.pixels[(row * source.height / logical.height) * source.width - + column * source.width / logical.width]; - } - } - logical - }; - if ground { - shade_ground_shadow(canvas, &mask, x, y, &self.shadow_table); - } else { - shade_air_shadow(canvas, &mask, x, y, &self.air_shadow_table); - } - Ok(()) - } - fn projectiles( - &mut self, - canvas: &mut Canvas, - world: &World, - hostile: bool, - alpha: f32, - ) -> Result<()> { - for p in world.projectiles.iter().filter(|p| p.hostile == hostile) { - let def = self.active.registry.weapons[&p.weapon].clone(); - let x = p.px as f32 + (p.x - p.px) as f32 * alpha; - let y = p.py as f32 + (p.y - p.py) as f32 * alpha; - if p.legacy.is_some() || p.legacy_enemy.is_some() { - if let Some(asset) = World::projectile_asset(p, &self.active.registry) { - let frame = if let Some(shot) = &p.legacy_enemy { - shot.art_frame % shot.frames.max(1) - } else { - p.legacy.as_ref().map_or(0, |shot| shot.art_frame) - }; - let original = self.raw(asset + frame)?; - let hd = if self.config.hd_mode { - self.hd.sprite(asset + frame, canvas.scale, &self.palette)? - } else { - None - }; - let sprite = match hd { - Some(sprite) => sprite, - None => self - .cache - .get(&original, canvas.scale, prefers_nearest(asset + frame))? - .clone(), - }; - canvas.blit( - &sprite, - (x * canvas.scale as f32).round() as i32, - (y * canvas.scale as f32).round() as i32, - ); - } - } else if matches!(def.behavior, crate::content::WeaponBehavior::Beam) { - canvas.rect(x as i32, 0, 2, world.player.y, 255); - } else if let Some(art) = def.art { - let owner = self.active.registry.owners[&p.weapon].clone(); - self.art( - canvas, - &art, - &owner, - p.age / 2, - x - art.logical_width as f32 / 2.0, - y - art.logical_height as f32 / 2.0, - )?; - } else { - canvas.rect(x as i32, y as i32, 2, 4, if p.hostile { 34 } else { 255 }); - } - } - Ok(()) - } - fn effects( - &mut self, - canvas: &mut Canvas, - world: &World, - ground: bool, - alpha: f32, - ) -> Result<()> { - for effect in world.effects.iter().filter(|e| e.ground == ground) { - let (asset, frames) = effect.kind.artwork(); - let frame = effect.age.saturating_sub(1).min(frames - 1); - let original = self.raw(asset + frame)?; - let hd = if self.config.hd_mode { - self.hd.sprite(asset + frame, canvas.scale, &self.palette)? - } else { - None - }; - let sprite = match hd { - Some(sprite) => sprite, - None => self - .cache - .get(&original, canvas.scale, prefers_nearest(asset + frame))? - .clone(), - }; - let x = effect.px as f32 + (effect.x - effect.px) as f32 * alpha - - original.width as f32 / 2.0; - let y = effect.py as f32 + (effect.y - effect.py) as f32 * alpha - - original.height as f32 / 2.0; - canvas.blit( - &sprite, - (x * canvas.scale as f32).round() as i32, - (y * canvas.scale as f32).round() as i32, - ); - } - Ok(()) - } - fn scene(&mut self, canvas: &mut Canvas) -> Result<()> { - let Some(world) = self.world.clone() else { - return Ok(()); - }; - let mission = self.active.registry.missions[&world.mission].clone(); - let alpha = self.clock.alpha(); - let interp = - |current: i32, previous: i32| previous as f32 + (current - previous) as f32 * alpha; - // Compose a joined terrain layer with a two-pixel gutter before filtering. - let mut terrain = Canvas::new(1); - terrain.image = Sprite { - width: 320, - height: 204, - pixels: vec![0; 320 * 204], - }; - let rows = if mission.columns > 0 { - mission.layout.len() / mission.columns as usize - } else { - 0 - }; - let offset = 200 - rows as i32 * 32 + world.scroll; - for (row, tiles) in mission - .layout - .chunks(mission.columns.max(1) as usize) - .enumerate() - { - let y = offset + row as i32 * 32; - if !(-34..202).contains(&y) { - continue; - } - for (col, index) in tiles.iter().enumerate() { - let n = row * mission.columns as usize + col; - let tile = &mission.tiles[*index]; - let art = if if mission.legacy_map.is_some() { - world.terrain_settled[n] - } else { - world.terrain_dead[n] - } { - tile.destroyed.as_ref().unwrap_or(&tile.art) - } else { - &tile.art - }; - let owner = self.active.registry.owners[&world.mission].clone(); - let mut original = art.clone(); - original.hd_png = None; - self.art( - &mut terrain, - &original, - &owner, - 0, - 16.0 + col as f32 * 32.0, - (y + 2) as f32, - )?; - } - } - let bg = upscale(&terrain.image, canvas.scale)?; - let shift = if world.tick > 0 { alpha - 1.0 } else { 0.0 }; - canvas.blit( - &bg, - 0, - (-2.0 * canvas.scale as f32 + shift * canvas.scale as f32).round() as i32, - ); - if self.config.hd_mode { - // Keep detailed terrain on the presentation canvas. The joined - // SD layer underneath supplies context and fallback at its edges. - for (row, tiles) in mission - .layout - .chunks(mission.columns.max(1) as usize) - .enumerate() - { - let y = offset + row as i32 * 32; - if !(-34..202).contains(&y) { - continue; - } - for (col, index) in tiles.iter().enumerate() { - let n = row * mission.columns as usize + col; - let tile = &mission.tiles[*index]; - let dead = if mission.legacy_map.is_some() { - world.terrain_settled[n] - } else { - world.terrain_dead[n] - }; - let art = if dead { - tile.destroyed.as_ref().unwrap_or(&tile.art) - } else { - &tile.art - }; - if art.hd_png.is_some() { - let owner = self.active.registry.owners[&world.mission].clone(); - self.art( - canvas, - art, - &owner, - 0, - 16.0 + col as f32 * 32.0, - y as f32 + shift, - )?; - } - } - } - } - let dark = matches!( - world.mission.0.as_str(), - "base:sector_2_wave_8" | "base:sector_3_wave_3" - ); - let player = self.catalog.id(if dark { - if world.player_flash { - "FPLAYER_PIC" - } else { - "DPLAYER_PIC" - } - } else { - "LPLAYER_PIC" - })?; - let player_frame = player + world.player.bank.clamp(0, 6) as u32; - for ground in [true, false] { - let mut shadows = 0; - for e in &world.enemies { - let def = self.active.registry.enemies[&e.definition].clone(); - if def.ground != ground - || def - .legacy - .as_ref() - .is_some_and(|legacy| legacy.fields[11] == 0) - { - continue; - } - if shadows >= if ground { 25 } else { 50 } { - break; - } - if let Some(art) = def.art { - let owner = self.active.registry.owners[&e.definition].clone(); - self.shadow( - canvas, - &art, - &owner, - if def.legacy.is_some() { - e.art_frame - } else { - e.frame - }, - interp(e.x, e.px), - interp(e.y, e.py), - ground, - )?; - shadows += 1; - } - } - if !ground && shadows < 50 && world.draw_player { - let mask = self.raw(player_frame)?; - shade_air_shadow( - canvas, - &mask, - interp(world.player.x, world.player.px), - interp(world.player.y, world.player.py), - &self.air_shadow_table, - ); - } - if !ground { - self.effects(canvas, &world, true, alpha)?; - } - for e in &world.enemies { - let def = self.active.registry.enemies[&e.definition].clone(); - if def.ground != ground { - continue; - } - if let Some(art) = def.art { - let owner = self.active.registry.owners[&e.definition].clone(); - self.art( - canvas, - &art, - &owner, - if def.legacy.is_some() { - e.art_frame - } else { - e.frame - }, - interp(e.x, e.px), - interp(e.y, e.py), - )?; - } - } - } - self.projectiles(canvas, &world, false, alpha)?; - if world.draw_player { - let s = self.raw(player_frame)?; - let hd = if self.config.hd_mode { - self.hd.sprite(player_frame, canvas.scale, &self.palette)? - } else { - None - }; - let scaled = match hd { - Some(sprite) => sprite, - None => self.cache.get(&s, canvas.scale, false)?.clone(), - }; - canvas.blit( - &scaled, - (interp(world.player.x, world.player.px) * canvas.scale as f32) as i32, - (interp(world.player.y, world.player.py) * canvas.scale as f32) as i32, - ); - } - for pickup in &world.pickups { - if pickup - .legacy - .as_ref() - .is_some_and(|legacy| legacy.countdown.is_some()) - { - if let Ok(asset) = self.catalog.id("N$_PIC") { - let original = self.raw(asset)?; - let sprite = self.cache.get(&original, canvas.scale, false)?.clone(); - canvas.blit( - &sprite, - pickup.x * canvas.scale as i32, - pickup.y * canvas.scale as i32, - ); - } - continue; - } - if let Some(art) = self.active.registry.items[&pickup.item].art.clone() { - let owner = self.active.registry.owners[&pickup.item].clone(); - self.art( - canvas, - &art, - &owner, - pickup - .legacy - .as_ref() - .map_or((world.tick / 3) as u32, |legacy| legacy.art_frame), - pickup.x as f32, - pickup.y as f32, - )?; - } - } - self.effects(canvas, &world, false, alpha)?; - self.projectiles(canvas, &world, true, alpha)?; - // The original shield bars sit beside the playfield. A full-width - // bottom panel would cover the player's engines at its initial y=160. - canvas.rect(0, 0, 16, 200, 0); - canvas.rect(304, 0, 16, 200, 0); - for (x, amount) in [(8, world.player.super_shield), (308, world.player.shield)] { - for level in 0..amount.clamp(0, 100) { - canvas.rect(x, 199 - level * 2, 4, 1, (74 - level * 27 / 100) as u16); - } - } - self.font.text( - canvas, - 132, - 2, - &format!("${:08}", world.player.credits), - 100, - ); - Ok(()) - } - fn draw(&mut self) -> Result { - let mut canvas = Canvas::new(self.config.scale); - canvas.clear(0); - if let Some(movie) = &self.cinematic { - canvas.blit(&nearest(&movie.image, canvas.scale), 0, 0); - return Ok(canvas); - } - if self.world.is_some() { - self.scene(&mut canvas)?; - if self.screen == Screen::Game { - return Ok(canvas); - } - } else if let Ok(id) = self.catalog.id("NEWMAIN_PIC") { - let s = self.raw(id)?; - canvas.blit(&nearest(&s, canvas.scale), 0, 0); - } - canvas.rect(12, 8, 296, 184, closest(&self.palette, [10, 15, 32])); - canvas.rect(12, 8, 296, 1, 100); - canvas.rect(12, 191, 296, 1, 100); - let title = match self.screen { - Screen::Main => "RAPTOR / RUST", - Screen::Missions => "MISSION SELECT", - Screen::Shop => "HANGAR / SHOP", - Screen::Inventory => "INVENTORY / EQUIPMENT", - Screen::Mods => "MOD MANAGER", - Screen::Options => "OPTIONS", - Screen::Pause => "PAUSED", - Screen::Results => "FLIGHT REPORT", - Screen::Help => "FLIGHT CONTROLS", - Screen::Credits => "CREDITS", - Screen::Pilots => "LOAD PILOT", - Screen::Diagnostics => "MOD / SAVE DIAGNOSTICS", - Screen::Bindings => "KEYBOARD BINDINGS", - Screen::Cinematic => "", - Screen::Game => "", - }; - self.font.text(&mut canvas, 22, 17, title, 50); - self.font.text( - &mut canvas, - 22, - 29, - &format!("PILOT {} ${}", self.pilot.callsign, self.pilot.credits), - 100, - ); - let rows = self.rows(); - let card = if self.screen == Screen::Missions { - self.missions().get(self.selected).and_then(|id| { - self.active.registry.missions[id] - .art - .clone() - .map(|art| (art, self.active.registry.owners[id].clone())) - }) - } else { - None - }; - let first = self.selected.saturating_sub(8); - for (index, row) in rows.iter().enumerate().skip(first).take(10) { - let y = 46 + (index - first) as i32 * 11; - if index == self.selected { - canvas.rect( - 20, - y - 2, - if card.is_some() { 126 } else { 280 }, - 10, - closest(&self.palette, [25, 45, 65]), - ); - } - self.font.text( - &mut canvas, - 24, - y, - &if card.is_some() { - row.chars().take(20).collect::() - } else { - row.clone() - }, - if index == self.selected { 50 } else { 100 }, - ); - } - if let Some((mut art, owner)) = card { - let height = (150 * art.logical_height / art.logical_width).min(108); - art.logical_width = 150; - art.logical_height = height; - self.art(&mut canvas, &art, &owner, 0, 154.0, 52.0)?; - } - if self.screen == Screen::Mods { - if self.selected >= 2 { - if let Some(id) = self.mod_ids().get(self.selected - 2) { - let m = &self.discovery.mods[id]; - self.font - .text(&mut canvas, 22, 160, &m.manifest.description, 100); - } - } - self.font.text( - &mut canvas, - 22, - 171, - "ENTER toggle/apply LEFT/RIGHT priority", - 50, - ); - } else if self.screen == Screen::Help { - let key = |action: &str| { - self.config - .bindings - .get(action) - .map(String::as_str) - .unwrap_or("unbound") - }; - for (n, line) in [ - format!( - "Move: {} / {} / {} / {}", - key("left"), - key("right"), - key("up"), - key("down") - ), - format!("Fire: {} Special: {}", key("fire"), key("special")), - format!("Cycle: {} Mega bomb: {}", key("cycle"), key("bomb")), - "ESC: pause O: options".into(), - "Mouse / controller selectable in Options".into(), - "ENTER: choose ESC: back".into(), - "Shop: ENTER buy Inventory: S sell".into(), - ] - .iter() - .enumerate() - { - self.font - .text(&mut canvas, 24, 49 + n as i32 * 14, line, 100); - } - } else if self.screen == Screen::Credits { - for (n, line) in [ - "Raptor: Cygnus Studios / Apogee", - "Reconstructed engine: nukeykt / skynettx", - "Rust replica: GPL v2 or later", - "FM core: Nuked-OPL3 / Tony Gies", - "Game assets: your original purchased copy", - ] - .iter() - .enumerate() - { - self.font - .text(&mut canvas, 24, 52 + n as i32 * 16, line, 100); - } - } else { - self.font.text( - &mut canvas, - 22, - 171, - "ENTER choose ESC back N new pilot D details", - 50, - ); - } - if !self.message.is_empty() { - self.font.text(&mut canvas, 22, 181, &self.message, 34); - } - Ok(canvas) - } -} -const ACTIONS: [&str; 8] = [ - "up", "down", "left", "right", "fire", "special", "cycle", "bomb", -]; -fn binding_matches(config: &Config, action: &str, key: Option) -> bool { - key.is_some() - && config - .bindings - .get(action) - .and_then(|value| Scancode::from_name(value)) - == key -} -// A failed load uses a display placeholder; never validate that placeholder -// in place of the existing file when mods are changed. -fn pilot_for_mods( - path: &std::path::Path, - current: &Pilot, - registry: &Registry, - pins: &[modding::ModPin], -) -> Result { - if path.exists() { - Pilot::load(path, registry, pins) - } else { - current.validate(registry, pins)?; - Ok(current.clone()) - } -} -fn restore_scripts(runtime: &mut modding::ScriptRuntime, pilot: &Pilot) { - runtime.states = pilot.script_state.clone(); - runtime.rng = pilot.script_rng.clone(); -} -fn mission_palette(base: &[[u8; 3]; 256], world: Option<&World>) -> [[u8; 3]; 256] { - let mut palette = *base; - let Some(world) = world else { - return palette; - }; - if world.tick < 3 { - return palette; - } - let updates = (world.tick - 1) / 2; - let water = (updates - (updates + 3) / 4) % 5; - let fire = (updates / 2) % 5; - for index in 0..5 { - palette[240 + index] = base[240 + (index + water as usize) % 5]; - palette[245 + index] = base[245 + (index + fire as usize) % 5]; - } - const GLOW: [u8; 20] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]; - for (index, phase) in [(250, 0_u64), (251, 8_u64)] { - let amount = GLOW[((phase + updates - 1) % 20) as usize]; - palette[index] = base[index].map(|value| { - let value = (value >> 2).saturating_sub(amount); - (value << 2) | (value >> 4) - }); - } - if updates >= 2 { - palette[if (updates / 2 - 1) % 2 == 0 { 252 } else { 253 }] = [0; 3]; - } - if !world.palette_blue { - palette[254] = [0; 3]; - } - if (updates - 1) % 6 >= 3 { - palette[255] = [0; 3]; - } - palette -} -fn closest(palette: &[[u8; 3]; 256], rgb: [u8; 3]) -> u16 { - palette - .iter() - .enumerate() - .min_by_key(|(_, c)| { - (0..3) - .map(|i| (c[i] as i32 - rgb[i] as i32).pow(2)) - .sum::() - }) - .unwrap() - .0 as u16 -} -fn viewport(w: u32, h: u32, aspect: bool) -> Rect { - if aspect { - let width = w.min(h * 4 / 3); - let height = h.min(w * 3 / 4); - Rect::new( - ((w - width) / 2) as i32, - ((h - height) / 2) as i32, - width, - height, - ) - } else { - Rect::new(0, 0, w, h) - } -} -pub fn run( - catalog: Catalog, - base: Registry, - paths: Paths, - mods_root: PathBuf, - frames: Option, - screenshot: Option, - preview: Option, - movie_preview: Option, - benchmark: bool, -) -> Result<()> { - let setup_start = Instant::now(); - let mut host = Host::new(catalog, base, paths, mods_root, frames.is_some())?; - if let Some(id) = preview { - if frames.is_some() { - host.new_pilot()?; - } - host.start(id)?; - } - if let Some(kind) = movie_preview { - host.show_movie(kind, Screen::Main)?; - } - let sdl = sdl2::init().map_err(|e| anyhow!(e))?; - let video = sdl.video().map_err(|e| anyhow!(e))?; - let window = video - .window( - "RaptorReplica - Rust", - host.config.width, - host.config.height, - ) - .position_centered() - .resizable() - .allow_highdpi() - .build()?; - let mut canvas = match window.into_canvas().accelerated().present_vsync().build() { - Ok(c) => c, - Err(_) => video - .window( - "RaptorReplica - Rust", - host.config.width, - host.config.height, - ) - .resizable() - .build()? - .into_canvas() - .software() - .build()?, - }; - let creator = canvas.texture_creator(); - let mut texture = creator.create_texture_streaming( - PixelFormatEnum::RGBA32, - (320 * host.config.scale) as u32, - (200 * host.config.scale) as u32, - )?; - let mut applied = (0usize, false, false, 0u32, 0u32); - let mut events = sdl.event_pump().map_err(|e| anyhow!(e))?; - let controllers = sdl.game_controller().map_err(|e| anyhow!(e))?; - let mut controller = (0..controllers.num_joysticks().unwrap_or(0)) - .filter(|index| controllers.is_game_controller(*index)) - .find_map(|index| controllers.open(index).ok()); - if let Ok(audio) = sdl.audio() { - let genmidi = host.catalog.named("GENMIDI_OP2")?; - let desired = AudioSpecDesired { - freq: Some(44100), - channels: Some(2), - samples: Some(512), - }; - match audio.open_playback(None, &desired, |spec| { - Mixer::new(spec.freq as u32, &genmidi).expect("validated instrument table") - }) { - Ok(device) => { - let mut device = device; - device.lock().set_fx_channels(host.config.fx_channels); - device.resume(); - host.audio = Some(device) - } - Err(e) => host.message = format!("Audio unavailable: {e}"), - } - } - if let Some(music) = host.desired_music { - if let Ok(score) = host.catalog.read(music).and_then(|b| MusScore::decode(&b)) { - if let Some(audio) = &mut host.audio { - audio.lock().play_with_fade(score, true, true); - } - } - } else { - host.music("MAINMENU_MUS"); - } - let mut previous = Instant::now(); - let setup_ms = setup_start.elapsed().as_secs_f64() * 1000.0; - let mut work_times = Vec::new(); - let mut frame_times = Vec::new(); - let mut count = 0u32; - let mut cycle = false; - let mut bomb = false; - let mut quit = false; - while !quit { - let frame_start = Instant::now(); - for event in events.poll_iter() { - match event { - Event::Quit { .. } => quit = true, - Event::ControllerDeviceAdded { which, .. } => { - controller = controllers.open(which).ok(); - } - Event::ControllerDeviceRemoved { which, .. } => { - if controller - .as_ref() - .is_some_and(|c| c.instance_id() == which) - { - controller = (0..controllers.num_joysticks().unwrap_or(0)) - .filter(|index| controllers.is_game_controller(*index)) - .find_map(|index| controllers.open(index).ok()); - } - } - Event::ControllerButtonDown { button, .. } => { - if host.screen == Screen::Cinematic { - host.finish_movie(); - continue; - } - if button == Button::Start { - if let Err(e) = host.back() { - host.message = e.to_string() - } - } else if host.screen != Screen::Game { - match button { - Button::DPadUp => host.selected = host.selected.saturating_sub(1), - Button::DPadDown => { - host.selected = - (host.selected + 1).min(host.rows().len().saturating_sub(1)) - } - Button::A => match host.activate() { - Ok(r) => quit = !r, - Err(e) => host.message = format!("{e:#}"), - }, - Button::B => { - if let Err(e) = host.back() { - host.message = e.to_string(); - } - } - _ => {} - } - } else if button == Button::X { - cycle = true - } else if button == Button::Y { - bomb = true - } - } - Event::MouseWheel { y, .. } if host.screen != Screen::Game => { - if y > 0 { - host.selected = host.selected.saturating_sub(y as usize); - } else { - host.selected = host - .selected - .saturating_add(y.unsigned_abs() as usize) - .min(host.rows().len().saturating_sub(1)); - } - } - Event::MouseButtonDown { x, y, .. } if host.screen != Screen::Game => { - if host.screen == Screen::Cinematic { - host.finish_movie(); - 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; - if (20..300).contains(&xx) && (44..156).contains(&yy) { - let first = host.selected.saturating_sub(8); - let selection = first + ((yy - 44) / 11) as usize; - if selection < host.rows().len() { - host.selected = selection; - match host.activate() { - Ok(r) => quit = !r, - Err(e) => host.message = format!("{e:#}"), - } - } - } - } - Event::KeyDown { - keycode: Some(key), - scancode: physical_key, - keymod, - repeat: false, - .. - } => { - if host.screen == Screen::Cinematic { - host.finish_movie(); - continue; - } - if let Some(action) = host.awaiting_binding.take() { - if key == Keycode::Escape { - host.message = "Binding change cancelled".into(); - } else if let Some(scancode) = physical_key { - host.config - .bindings - .insert(action.clone(), scancode.name().to_string()); - host.message = format!("{action}: {}", scancode.name()); - if let Err(e) = host.save_config() { - host.message = e.to_string(); - } - } - continue; - } - if host.screen == Screen::Game - && ACTIONS - .iter() - .any(|action| binding_matches(&host.config, action, physical_key)) - { - cycle |= binding_matches(&host.config, "cycle", physical_key); - bomb |= binding_matches(&host.config, "bomb", physical_key); - continue; - } - if key == Keycode::Return - && keymod - .intersects(sdl2::keyboard::Mod::LALTMOD | sdl2::keyboard::Mod::RALTMOD) - { - host.config.fullscreen = !host.config.fullscreen; - continue; - } - if key == Keycode::Escape || key == Keycode::P && host.screen == Screen::Game { - if let Err(e) = host.back() { - host.message = e.to_string() - } - continue; - } - if host.screen == Screen::Game { - match key { - Keycode::O => { - host.previous = Screen::Pause; - host.go(Screen::Options) - } - _ if binding_matches(&host.config, "cycle", physical_key) => { - cycle = true - } - _ if binding_matches(&host.config, "bomb", physical_key) => bomb = true, - _ => {} - } - continue; - } - match key { - Keycode::Up => host.selected = host.selected.saturating_sub(1), - Keycode::Down => { - host.selected = - (host.selected + 1).min(host.rows().len().saturating_sub(1)) - } - Keycode::Return => match host.activate() { - Ok(r) => quit = !r, - Err(e) => host.message = format!("{e:#}"), - }, - Keycode::D if host.world.is_none() => { - host.previous = host.screen; - host.go(Screen::Diagnostics); - } - Keycode::N if host.world.is_none() => { - if let Err(e) = host.new_pilot() { - host.message = e.to_string(); - } - } - Keycode::S if host.screen == Screen::Inventory => { - if let Some(id) = - host.pilot.inventory.keys().nth(host.selected).cloned() - { - if let Err(e) = host - .pilot - .sell(&id, &host.active.registry) - .and_then(|_| host.save()) - { - host.message = e.to_string() - } - } - } - Keycode::Left | Keycode::Right - if host.screen == Screen::Mods && host.selected >= 2 => - { - if let Some(id) = host.mod_ids().get(host.selected - 2) { - if let Some(n) = host.pending.iter().position(|m| m == id) { - let next = if key == Keycode::Left { - n.saturating_sub(1) - } else { - (n + 1).min(host.pending.len() - 1) - }; - host.pending.swap(n, next); - } - } - } - _ => {} - } - } - _ => {} - } - } - let desired = ( - host.config.scale, - host.config.fullscreen, - host.config.borderless, - host.config.width, - host.config.height, - ); - if desired != applied { - if host.config.scale != applied.0 { - texture = creator.create_texture_streaming( - PixelFormatEnum::RGBA32, - (320 * host.config.scale) as u32, - (200 * host.config.scale) as u32, - )?; - } - if let Err(e) = canvas - .window_mut() - .set_fullscreen(if host.config.fullscreen { - FullscreenType::Desktop - } else { - FullscreenType::Off - }) - { - host.message = e; - } - canvas.window_mut().set_bordered(!host.config.borderless); - if !host.config.fullscreen { - let _ = canvas - .window_mut() - .set_size(host.config.width, host.config.height); - } - applied = desired; - } - let now = Instant::now(); - let elapsed = now.duration_since(previous).as_nanos().min(250_000_000) as u64; - let elapsed = if frames.is_some() { - 16_666_667 - } else { - elapsed - }; - previous = now; - let keyboard = events.keyboard_state(); - let pressed = |action: &str| { - host.config - .bindings - .get(action) - .and_then(|key| Scancode::from_name(key)) - .is_some_and(|key| keyboard.is_scancode_pressed(key)) - }; - let mut input = Input { - dx: pressed("right") as i32 - pressed("left") as i32, - dy: pressed("down") as i32 - pressed("up") as i32, - fire: pressed("fire") || frames.is_some() && host.screen == Screen::Game, - special: pressed("special"), - cycle, - bomb, - ..Input::default() - }; - if host.config.control == "mouse" { - let mouse = events.mouse_state(); - let (w, h) = canvas.window().size(); - let r = viewport(w, h, host.config.aspect_correct); - input.absolute = Some([ - (mouse.x() - r.x()) * 320 / r.width() as i32 - 16, - (mouse.y() - r.y()) * 200 / r.height() as i32 - 16, - ]); - input.fire = mouse.left(); - input.special = mouse.right(); - } - if host.config.control == "controller" { - if let Some(c) = &controller { - input.dx = if i32::from(c.axis(Axis::LeftX)).abs() > 8000 { - c.axis(Axis::LeftX).signum() as i32 - } else { - 0 - }; - input.dy = if i32::from(c.axis(Axis::LeftY)).abs() > 8000 { - c.axis(Axis::LeftY).signum() as i32 - } else { - 0 - }; - if input.dx == 0 { - input.dx = - c.button(Button::DPadRight) as i32 - c.button(Button::DPadLeft) as i32; - } - if input.dy == 0 { - input.dy = c.button(Button::DPadDown) as i32 - c.button(Button::DPadUp) as i32; - } - input.fire = c.button(Button::A); - input.special = c.button(Button::B); - } - } - if let Err(e) = host.update(input, elapsed) { - host.message = format!("Mission paused: {e:#}"); - host.go(Screen::Pause); - } - cycle = false; - bomb = false; - if let Some(audio) = &mut host.audio { - let mut mixer = audio.lock(); - mixer.set_fx_channels(host.config.fx_channels); - mixer.music_volume = host.config.music_volume; - mixer.fx_volume = host.config.fx_volume; - } - let image = host.draw()?; - let palette = if let Some(movie) = &host.cinematic { - movie.palette(&host.palette) - } else { - mission_palette(&host.palette, host.world.as_ref()) - }; - let rgba = image.rgba(&palette, host.config.scanlines); - 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(); - canvas - .copy(&texture, None, viewport(w, h, host.config.aspect_correct)) - .map_err(|e| anyhow!(e))?; - if frames.is_some_and(|n| count + 1 >= n) { - if let Some(path) = &screenshot { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let presented = canvas - .read_pixels(None, PixelFormatEnum::RGBA32) - .map_err(|e| anyhow!(e))?; - image::save_buffer(path, &presented, w, h, image::ColorType::Rgba8)?; - } - } - let work_ms = frame_start.elapsed().as_secs_f64() * 1000.0; - canvas.present(); - if benchmark && count >= 5 && !frames.is_some_and(|n| count + 1 >= n) { - work_times.push(work_ms); - frame_times.push(frame_start.elapsed().as_secs_f64() * 1000.0); - } - count += 1; - if frames.is_some_and(|n| count >= n) { - quit = true; - } - if frames.is_some() { - std::thread::sleep(Duration::from_millis(1)); - } - } - if benchmark && !work_times.is_empty() { - work_times.sort_by(f64::total_cmp); - frame_times.sort_by(f64::total_cmp); - let percentile = |values: &[f64], p: usize| values[(values.len() - 1) * p / 100]; - println!( - "benchmark renderer={} size={}x{} scale={} hd={} startup_ms={setup_ms:.2} samples={} work_p50_ms={:.2} work_p95_ms={:.2} frame_p50_ms={:.2} frame_p95_ms={:.2}", - canvas.info().name, - host.config.width, - host.config.height, - host.config.scale, - host.config.hd_mode, - work_times.len(), - percentile(&work_times, 50), - percentile(&work_times, 95), - percentile(&frame_times, 50), - percentile(&frame_times, 95) - ); - } - if frames.is_none() { - host.config.save(&host.paths)?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn loading_a_pilot_restores_scripts_before_hangar_saves() { - let (_, mut pilot) = fixture(); - pilot.script_state.insert( - "weapon:behavior.rhai".into(), - serde_json::json!({"shots": 42}), - ); - pilot.script_rng.insert( - "weapon:behavior.rhai".into(), - crate::simulation::LegacyRng::new(731), - ); - let mut runtime = modding::ScriptRuntime::new(); - restore_scripts(&mut runtime, &pilot); - let mut resaved = pilot.clone(); - resaved.script_state = runtime.states.clone(); - resaved.script_rng = runtime.rng.clone(); - assert_eq!(resaved.script_state, pilot.script_state); - assert_eq!(resaved.script_rng["weapon:behavior.rhai"].seed, 731); - restore_scripts(&mut runtime, &Pilot::default()); - assert!(runtime.states.is_empty()); - assert!(runtime.rng.is_empty()); - } - fn fixture() -> (Registry, Pilot) { - let mut registry = Registry::default(); - let mut pilot = Pilot::default(); - pilot.inventory.clear(); - let mission: crate::content::MissionDef = serde_json::from_value(serde_json::json!({ - "id": pilot.mission, "name": "Save safety fixture", "duration": 100 - })) - .unwrap(); - registry.missions.insert(mission.id.clone(), mission); - (registry, pilot) - } - #[test] - fn applying_mods_checks_saved_pilot_instead_of_display_placeholder() { - let (registry, placeholder) = fixture(); - assert!(placeholder.validate(®istry, &[]).is_ok()); - let pin = modding::ModPin { - id: "example".into(), - version: "1.0.0".into(), - hash: "required".into(), - }; - let mut saved = placeholder.clone(); - saved.mods = vec![pin.clone()]; - saved.callsign = "SAVED PILOT".into(); - saved.credits = 4242; - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("pilot.json"); - saved.save(&path).unwrap(); - let before = std::fs::read(&path).unwrap(); - assert!(pilot_for_mods(&path, &placeholder, ®istry, &[]).is_err()); - assert_eq!(std::fs::read(&path).unwrap(), before); - let restored = pilot_for_mods(&path, &placeholder, ®istry, &[pin]).unwrap(); - assert_eq!(restored.callsign, "SAVED PILOT"); - assert_eq!(restored.credits, 4242); - } - #[test] - fn corrupt_existing_slot_cannot_be_replaced_by_valid_placeholder() { - let (registry, placeholder) = fixture(); - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("pilot.json"); - std::fs::write(&path, b"corrupt existing save").unwrap(); - assert!(pilot_for_mods(&path, &placeholder, ®istry, &[]).is_err()); - assert_eq!(std::fs::read(&path).unwrap(), b"corrupt existing save"); - let unused = dir.path().join("unused.json"); - assert_eq!( - pilot_for_mods(&unused, &placeholder, ®istry, &[]) - .unwrap() - .callsign, - placeholder.callsign - ); - } - #[test] - fn full_hd_window_retains_centered_four_three_viewport() { - assert_eq!(viewport(1920, 1080, true), Rect::new(240, 0, 1440, 1080)); - assert_eq!(viewport(1920, 1080, false), Rect::new(0, 0, 1920, 1080)); - } - #[test] - fn palette_effects_do_not_consume_simulation_rng() { - let (registry, pilot) = fixture(); - let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); - world.tick = 7; - world.palette_blue = false; - let before = world.rng.seed; - let base = [[255, 128, 64]; 256]; - let first = mission_palette(&base, Some(&world)); - assert_eq!(first, mission_palette(&base, Some(&world))); - assert_eq!(world.rng.seed, before); - assert_eq!(first[254], [0; 3]); - world.palette_blue = true; - assert_eq!(mission_palette(&base, Some(&world))[254], base[254]); - } -} diff --git a/src/sound.rs b/src/sound.rs deleted file mode 100644 index 4c4c0719..00000000 --- a/src/sound.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! Checked package-local PCM WAV loading, independent of SDL2. -//! RIFF chunks are little-endian and padded to a two-byte boundary. -use crate::audio::DigitalSample; -use anyhow::{Context, Result, ensure}; - -pub fn decode(bytes: &[u8]) -> Result { - ensure!(bytes.len() <= 8 * 1024 * 1024, "sound exceeds 8 MiB"); - if !bytes.starts_with(b"RIFF") { - return DigitalSample::decode(bytes); - } - let u32_at = |offset| -> Result { - Ok(u32::from_le_bytes( - bytes - .get(offset..offset + 4) - .context("truncated WAV field")? - .try_into()?, - )) - }; - ensure!( - bytes.get(8..12) == Some(b"WAVE") && u32_at(4)? as usize == bytes.len().saturating_sub(8), - "invalid RIFF/WAVE length" - ); - let mut format = None; - let mut audio = None; - let mut offset = 12; - while offset < bytes.len() { - let tag = bytes - .get(offset..offset + 4) - .context("truncated WAV chunk")?; - let length = u32_at(offset + 4)? as usize; - let start = offset + 8; - let end = start - .checked_add(length) - .context("WAV chunk size overflow")?; - let chunk = bytes - .get(start..end) - .context("truncated WAV chunk payload")?; - match tag { - b"fmt " => { - ensure!( - format.is_none() && chunk.len() >= 16, - "duplicate/truncated WAV format" - ); - let u16_at = |n| u16::from_le_bytes([chunk[n], chunk[n + 1]]); - let rate = u32::from_le_bytes(chunk[4..8].try_into()?); - let channels = u16_at(2) as usize; - let bits = u16_at(14) as usize; - ensure!( - u16_at(0) == 1 - && matches!(channels, 1 | 2) - && matches!(bits, 8 | 16) - && (8000..=48000).contains(&rate), - "sound must be 8/16-bit PCM WAV, mono/stereo, 8–48 kHz" - ); - let align = channels * bits / 8; - ensure!( - u16_at(12) as usize == align - && u32::from_le_bytes(chunk[8..12].try_into()?) == rate * align as u32, - "invalid WAV block/byte rate" - ); - format = Some((rate, channels, bits, align)); - } - b"data" => { - ensure!(audio.is_none(), "duplicate WAV data"); - audio = Some(chunk); - } - _ => {} - } - offset = end - .checked_add(length & 1) - .context("WAV padding overflow")?; - ensure!(offset <= bytes.len(), "truncated WAV padding"); - } - let (rate, channels, bits, align) = format.context("WAV has no format")?; - let audio = audio.context("WAV has no audio data")?; - ensure!( - !audio.is_empty() && audio.len() % align == 0 && audio.len() / align <= rate as usize * 30, - "invalid WAV sample count (maximum 30 seconds)" - ); - let data = audio - .chunks_exact(align) - .map(|frame| { - let total: i32 = (0..channels) - .map(|channel| { - if bits == 8 { - (frame[channel] as i32 - 128) * 256 - } else { - i16::from_le_bytes([frame[channel * 2], frame[channel * 2 + 1]]) as i32 - } - }) - .sum(); - ((total / channels as i32) / 256 + 128).clamp(0, 255) as u8 - }) - .collect(); - Ok(DigitalSample { rate, data }) -} diff --git a/tests/sound.rs b/tests/sound.rs deleted file mode 100644 index cf88a9e5..00000000 --- a/tests/sound.rs +++ /dev/null @@ -1,119 +0,0 @@ -use raptor_replica::{ - content::{ContentPack, Registry}, - modding::{self, ActiveMods, Command, ScriptRuntime}, - persistence::Pilot, - simulation::{Input, Outcome, World}, - sound, -}; -use serde_json::json; - -fn wav(channels: u16, bits: u16, data: &[u8]) -> Vec { - let align = channels * bits / 8; - let mut bytes = b"RIFF".to_vec(); - bytes.extend((36u32 + data.len() as u32 + (data.len() as u32 % 2)).to_le_bytes()); - bytes.extend(b"WAVEfmt "); - bytes.extend(16u32.to_le_bytes()); - bytes.extend(1u16.to_le_bytes()); - bytes.extend(channels.to_le_bytes()); - bytes.extend(11025u32.to_le_bytes()); - bytes.extend((11025u32 * align as u32).to_le_bytes()); - bytes.extend(align.to_le_bytes()); - bytes.extend(bits.to_le_bytes()); - bytes.extend(b"data"); - bytes.extend((data.len() as u32).to_le_bytes()); - bytes.extend(data); - if data.len() % 2 == 1 { - bytes.push(0); - } - bytes -} -fn registry() -> Registry { - let mut registry = Registry::default(); - let pack: ContentPack=serde_json::from_value(json!({ - "items":[{"id":"base:energy","name":"Shield","price":0,"max_count":100}, - {"id":"base:forward_guns","name":"Guns","price":0}], - "missions":[{"id":"base:sector_1_wave_1","name":"Test","duration":1,"auto_complete":false,"script":"mission.rhai"}], - "sounds":[{"id":"base:test","name":"Test","file":"test.wav"}] - })).unwrap(); - registry.apply(pack, "base").unwrap(); - registry.validate().unwrap(); - registry -} -#[test] -fn wav_handles_padding_unsigned_eight_bit_and_stereo_sixteen_bit() { - let decoded = sound::decode(&wav(1, 8, &[0, 128, 255])).unwrap(); - assert_eq!(decoded.data, [0, 128, 255]); - assert_eq!(decoded.rate, 11025); - let samples: Vec<_> = [-32768i16, 32767, -32768, -32768, 32767, 32767] - .into_iter() - .flat_map(i16::to_le_bytes) - .collect(); - assert_eq!( - sound::decode(&wav(2, 16, &samples)).unwrap().data, - [128, 0, 255] - ); -} -#[test] -fn malformed_wave_ranges_formats_and_padding_fail() { - let bytes = wav(1, 8, &[128, 200, 0]); - for end in 0..bytes.len() { - assert!(sound::decode(&bytes[..end]).is_err()); - } - assert!(sound::decode(&wav(3, 8, &[0, 0, 0])).is_err()); - assert!(sound::decode(&wav(1, 32, &[0, 0, 0, 0])).is_err()); - let mut huge = bytes.clone(); - huge[40..44].copy_from_slice(&u32::MAX.to_le_bytes()); - assert!(sound::decode(&huge).is_err()); -} -#[test] -fn scripted_completion_emits_end_hook_once_and_sound_ids_are_checked() { - let registry = registry(); - let mut world = World::new(&Pilot::default().mission, &Pilot::default(), ®istry).unwrap(); - let mut scripts = ScriptRuntime::new(); - scripts.add("base","mission.rhai",r#" - fn on_tick(event,state,random) { #{commands:[#{op:"complete"}],state:state} } - fn on_mission_end(event,state,random) { #{commands:[#{op:"play_sound",sound:"base:test"}],state:#{ended:1}} } - "#).unwrap(); - world - .step(Input::default(), ®istry, &mut scripts) - .unwrap(); - assert_eq!(world.outcome, Outcome::Complete); - assert_eq!(world.mod_sounds.len(), 1); - world - .step(Input::default(), ®istry, &mut scripts) - .unwrap(); - assert_eq!(world.mod_sounds.len(), 1); - assert!( - world - .apply_commands( - &[Command::PlaySound { - sound: "base:missing".into() - }], - ®istry - ) - .is_err() - ); -} -#[test] -fn package_local_sounds_validate_and_missing_files_reject_activation() { - let dir = tempfile::tempdir().unwrap(); - let package = dir.path().join("tone"); - std::fs::create_dir_all(&package).unwrap(); - std::fs::write( - package.join("mod.toml"), - "id='tone'\nname='Tone'\nversion='1.0.0'\napi_version=1\ncontent='content.json'\n", - ) - .unwrap(); - std::fs::write( - package.join("content.json"), - serde_json::to_vec(&json!({"sounds":[{"id":"tone:shot","name":"Shot","file":"shot.wav"}]})) - .unwrap(), - ) - .unwrap(); - std::fs::write(package.join("shot.wav"), wav(1, 8, &[128, 200, 50])).unwrap(); - let found = modding::discover(dir.path()); - assert!(ActiveMods::build(&Registry::default(), &found, &["tone".into()], None).is_ok()); - std::fs::remove_file(package.join("shot.wav")).unwrap(); - let found = modding::discover(dir.path()); - assert!(ActiveMods::build(&Registry::default(), &found, &["tone".into()], None).is_err()); -}