emit.rs
9.8 kB · rust · 280 lines
1use crate::config::{Config, FPS, FREEZE_US, HEATMAP_FPS, INTER_FREEZE_US, SIZE};2use crate::frames::{frame_palette, heat_colorizer, span_peak};3use crate::music::{soundtrack, Score};4use crate::quest::{quest, Quest};5use crate::Path;6use mrlycore::errors::Result;7use mrlycore::io::{make, write};8use mrlycore::{json, state, Json};9use mrlymath::life::{frames_with, heatmap_range};10use mrlymusic::{audio, wav};11use std::path::Path as Dir;1213/// The default seed an emit runs under.14pub const SEED: u64 = 7;1516/// Generates a quest from the seed under the default caps and writes it into the directory.17pub fn emit(dir: &Dir, seed: u64) -> Result<Json> {18 emit_with(dir, seed, &Config::default())19}2021/// Generates a quest from the seed and writes frames, heatmap, audio and record into the directory.22pub fn emit_with(dir: &Dir, seed: u64, config: &Config) -> Result<Json> {23 state::seed(seed);24 let found = quest(config)?;25 write_frames(dir, &found)?;26 write_heatmap(dir, &found)?;27 let scores: Vec<Score> = found.segments.iter().map(|s| s.score.clone()).collect();28 let samples = soundtrack(&scores, &found.story.chapter_lengths());29 write(30 &dir.join("audio.wav"),31 &wav(&audio::pcm(&samples), audio::RATE),32 )?;33 let record = record(&found, seed)?;34 let text = serde_json::to_string_pretty(&record)? + "\n";35 write(&dir.join("quest.json"), text.as_bytes())?;36 Ok(record)37}3839fn write_frames(dir: &Dir, found: &Quest) -> Result<()> {40 let home = dir.join("frames");41 make(&home)?;42 let lengths = found.story.chapter_lengths();43 let mut cursor = 0;44 let mut number = 0;45 for (i, segment) in found.segments.iter().enumerate() {46 let span = &found.grids[cursor..cursor + lengths[i]];47 let wanted = segment.mask.types().sum() as usize + 1;48 let palette = frame_palette(segment.accent, segment.path == Path::Simple, wanted);49 let pngs = frames_with(50 span,51 &segment.mask,52 found.boundary,53 1,54 found.primary.color(),55 &palette,56 )?;57 for png in &pngs {58 number += 1;59 write(&home.join(format!("{number:04}.png")), png)?;60 }61 cursor += lengths[i];62 }63 Ok(())64}6566fn write_heatmap(dir: &Dir, found: &Quest) -> Result<()> {67 let home = dir.join("heatmap");68 make(&home)?;69 let lengths = found.story.chapter_lengths();70 let mut cursor = 0;71 let mut number = 0;72 for (i, segment) in found.segments.iter().enumerate() {73 let end = cursor + lengths[i];74 let peak = span_peak(&found.grids[cursor..end]);75 let colorizer = heat_colorizer(found.primary, segment.accent, peak)?;76 let pngs = heatmap_range(&found.grids, cursor, end, &colorizer, 1)?;77 for png in &pngs {78 number += 1;79 write(&home.join(format!("{number:04}.png")), png)?;80 }81 cursor = end;82 }83 Ok(())84}8586fn timeline(lengths: &[usize], frame_us: u64) -> Vec<Json> {87 let total: usize = lengths.iter().sum();88 if total == 0 {89 return Vec::new();90 }91 let mut out = Vec::with_capacity(total + lengths.len() + 1);92 out.push(json!({ "frame": 1, "us": FREEZE_US }));93 let mut cursor = 0;94 for (i, &length) in lengths.iter().enumerate() {95 for frame in cursor..cursor + length {96 out.push(json!({ "frame": frame + 1, "us": frame_us }));97 }98 cursor += length;99 if i < lengths.len() - 1 {100 out.push(json!({ "frame": cursor, "us": INTER_FREEZE_US }));101 }102 }103 out.push(json!({ "frame": total, "us": FREEZE_US }));104 out105}106107fn record(found: &Quest, seed: u64) -> Result<Json> {108 let lengths = found.story.chapter_lengths();109 Ok(json!({110 "v": 1,111 "key": found.key.clone(),112 "name": found.name(),113 "seed": seed.to_string(),114 "story": found.story.to_json()?,115 "manifest": {116 "fps": FPS,117 "heatmap_fps": HEATMAP_FPS,118 "canvas": found.grids.first().map_or(0, |g| g.width()),119 "size": SIZE,120 "audio": "audio.wav",121 "segments": lengths.clone(),122 "freeze_us": FREEZE_US,123 "inter_freeze_us": INTER_FREEZE_US,124 "frames": timeline(&lengths, 1_000_000 / FPS as u64),125 "heatmap": timeline(&lengths, 1_000_000 / HEATMAP_FPS as u64),126 },127 }))128}129130#[cfg(test)]131mod tests {132 use super::*;133 use mrlycore::state::guard;134 use std::fs;135136 const PINNED: u64 = 1;137138 fn tiny() -> Config {139 Config {140 max_generations: 16,141 max_segments: 4,142 max_canvas: 15,143 min_tile: 3,144 max_tile: 5,145 min_mask: 3,146 max_mask: 5,147 attempts: 64,148 }149 }150151 fn scratch(name: &str) -> std::path::PathBuf {152 std::env::temp_dir().join(format!("mrlygame_{name}_{}", std::process::id()))153 }154155 #[test]156 fn two_emits_press_identical_bytes() {157 let _g = guard();158 let (a, b) = (scratch("emita"), scratch("emitb"));159 make(&a).unwrap();160 make(&b).unwrap();161 emit_with(&a, PINNED, &tiny()).unwrap();162 emit_with(&b, PINNED, &tiny()).unwrap();163 for name in ["quest.json", "audio.wav"] {164 let x = fs::read(a.join(name)).unwrap();165 let y = fs::read(b.join(name)).unwrap();166 assert_eq!(x, y, "{name} drifted between emits");167 }168 for folder in ["frames", "heatmap"] {169 let x = fs::read(a.join(folder).join("0001.png")).unwrap();170 let y = fs::read(b.join(folder).join("0001.png")).unwrap();171 assert_eq!(x, y, "{folder} drifted between emits");172 }173 fs::remove_dir_all(&a).ok();174 fs::remove_dir_all(&b).ok();175 }176177 #[test]178 fn the_emitted_folder_holds_the_whole_product() {179 let _g = guard();180 let dir = scratch("press");181 make(&dir).unwrap();182 let record = emit_with(&dir, PINNED, &tiny()).unwrap();183 let count = record["manifest"]["segments"]184 .as_array()185 .unwrap()186 .iter()187 .map(|n| n.as_u64().unwrap() as usize)188 .sum::<usize>();189 assert!(count > 0);190 for folder in ["frames", "heatmap"] {191 for frame in 1..=count {192 let path = dir.join(folder).join(format!("{frame:04}.png"));193 let bytes = fs::read(&path).unwrap();194 assert_eq!(&bytes[1..4], b"PNG");195 }196 assert!(!dir197 .join(folder)198 .join(format!("{:04}.png", count + 1))199 .exists());200 }201 let wav = fs::read(dir.join("audio.wav")).unwrap();202 assert_eq!(&wav[0..4], b"RIFF");203 assert!(wav.len() > 44 + audio::RATE);204 assert!(wav[44..].iter().any(|&b| b != 0));205 fs::remove_dir_all(&dir).ok();206 }207208 #[test]209 fn the_record_carries_the_assembly_manifest() {210 let _g = guard();211 let dir = scratch("record");212 make(&dir).unwrap();213 let record = emit_with(&dir, PINNED, &tiny()).unwrap();214 assert_eq!(record["v"], json!(1));215 let key = record["key"].as_str().unwrap().to_string();216 assert_eq!(record["name"].as_str(), Some(&*format!("mrly_quest_{key}")));217 assert_eq!(218 record["seed"].as_str().and_then(|s| s.parse::<u64>().ok()),219 Some(PINNED)220 );221 assert!(record["story"]["chapters"].as_array().is_some());222 let manifest = &record["manifest"];223 assert_eq!(manifest["fps"], json!(FPS));224 assert_eq!(manifest["heatmap_fps"], json!(HEATMAP_FPS));225 assert_eq!(manifest["size"], json!(SIZE));226 assert_eq!(manifest["audio"], json!("audio.wav"));227 assert_eq!(manifest["freeze_us"], json!(FREEZE_US));228 assert_eq!(manifest["inter_freeze_us"], json!(INTER_FREEZE_US));229 assert!(manifest["canvas"].as_u64().unwrap() > 0);230 let lengths: Vec<usize> = manifest["segments"]231 .as_array()232 .unwrap()233 .iter()234 .map(|n| n.as_u64().unwrap() as usize)235 .collect();236 let total: usize = lengths.iter().sum();237 for (name, fps) in [("frames", FPS), ("heatmap", HEATMAP_FPS)] {238 let entries = manifest[name].as_array().unwrap();239 assert_eq!(entries.len(), total + lengths.len() + 1);240 assert_eq!(entries[0]["us"].as_u64(), Some(FREEZE_US));241 assert_eq!(entries[1]["us"].as_u64(), Some(1_000_000 / fps as u64));242 assert_eq!(243 entries.last().unwrap()["frame"].as_u64(),244 Some(total as u64)245 );246 }247 fs::remove_dir_all(&dir).ok();248 }249250 #[test]251 fn a_top_bit_seed_survives_the_record_and_replays() {252 let _g = guard();253 let seed = u64::MAX - 41;254 let (a, b) = (scratch("bigseeda"), scratch("bigseedb"));255 make(&a).unwrap();256 make(&b).unwrap();257 let record = emit_with(&a, seed, &tiny()).unwrap();258 assert_eq!(record["seed"].as_str(), Some(seed.to_string().as_str()));259 let stored: u64 = record["seed"].as_str().unwrap().parse().unwrap();260 emit_with(&b, stored, &tiny()).unwrap();261 let x = fs::read(a.join("quest.json")).unwrap();262 let y = fs::read(b.join("quest.json")).unwrap();263 assert_eq!(x, y, "the recorded seed replayed a different quest");264 fs::remove_dir_all(&a).ok();265 fs::remove_dir_all(&b).ok();266 }267268 #[test]269 fn timelines_hold_the_freeze_policy() {270 let frames = timeline(&[2, 3], 125_000);271 assert_eq!(frames.len(), 2 + 3 + 3);272 assert_eq!(frames[0], json!({ "frame": 1, "us": FREEZE_US }));273 assert_eq!(frames[3], json!({ "frame": 2, "us": INTER_FREEZE_US }));274 assert_eq!(275 frames.last().unwrap(),276 &json!({ "frame": 5, "us": FREEZE_US })277 );278 assert!(timeline(&[], 125_000).is_empty());279 }280}