Initial implementation - still very much WIP

This commit is contained in:
Jerry Starke 2026-06-06 03:10:50 +02:00
commit 90c3246f3e
8 changed files with 4159 additions and 0 deletions

1365
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

10
Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "ikamaki"
version = "0.1.0"
edition = "2024"
[dependencies]
chrono = "0.4"
clap = { version = "4.6", features = ["derive"] }
rand = "0.10"
roead = { version = "1.0", default-features = false, features = ["byml", "yaml"] }

BIN
VSSetting.byaml Normal file

Binary file not shown.

2364
VSSetting.yml Normal file

File diff suppressed because it is too large Load diff

261
src/exporter.rs Normal file
View file

@ -0,0 +1,261 @@
use crate::schedule::Schedule;
use roead::Endian;
use roead::byml::Byml;
/*
Fixes the formatting in the human readable YAML.
Not the prettiest, but it works.
*/
fn fix_yaml(byml: &Byml) -> Byml {
match byml {
Byml::I32(v) => Byml::I64(*v as i64),
Byml::Array(arr) => Byml::Array(arr.iter().map(fix_yaml).collect()),
Byml::Map(map) => {
let mut new_map = roead::byml::Map::default();
new_map.reserve(map.len());
for (k, v) in map.iter() {
new_map.insert(k.clone(), fix_yaml(v));
}
Byml::Map(new_map)
}
_ => byml.clone(),
}
}
const MAP_FIRST_APPEAR: [(&str, i32); 16] = [
("2015-04-17", 0),
("2015-04-17", 1),
("2015-04-17", 2),
("2015-04-17", 3),
("2015-04-17", 4),
("2015-06-02", 6),
("2015-06-11", 7),
("2015-06-20", 9),
("2015-07-11", 8),
("2015-07-25", 5),
("2015-08-21", 11),
("2015-09-18", 10),
("2015-11-14", 12),
("2015-12-04", 15),
("2015-12-29", 14),
("2016-01-22", 13),
];
const RULE_FIRST_APPEAR: [(&str, &str); 3] = [
("2015-06-02", "cVar"),
("2015-07-02", "cVlf"),
("2015-08-15", "cVgl"),
];
const WEAPON_UNLOCK: [(&str, i32); 58] = [
("2015-06-02", 1060),
("2015-06-06", 2030),
("2015-06-13", 1020),
("2015-06-17", 1160),
("2015-06-17", 4031),
("2015-06-24", 1110),
("2015-06-27", 2000),
("2015-06-27", 1091),
("2015-07-08", 1000),
("2015-07-08", 1081),
("2015-07-18", 2040),
("2015-07-18", 1061),
("2015-07-22", 4040),
("2015-07-22", 1021),
("2015-08-01", 1130),
("2015-08-01", 2031),
("2015-08-06", 3000),
("2015-08-06", 5010),
("2015-08-29", 4050),
("2015-08-29", 1161),
("2015-09-05", 5000),
("2015-09-12", 1170),
("2015-09-26", 3010),
("2015-10-03", 2001),
("2015-10-10", 1131),
("2015-10-10", 4041),
("2015-10-17", 1150),
("2015-10-30", 1111),
("2015-10-30", 1171),
("2015-11-07", 5011),
("2015-11-11", 1001),
("2015-11-18", 4051),
("2015-11-21", 5020),
("2015-11-25", 3001),
("2015-11-28", 3020),
("2015-12-12", 5001),
("2015-12-19", 3011),
("2015-12-25", 1151),
("'2016-01-01T19:00:00+09:00'", 2041),
("2016-01-09", 3021),
("2016-01-16", 5021),
("'2016-04-13T11:00:00+09:00'", 1042),
("'2016-04-13T11:00:00+09:00'", 1072),
("'2016-04-13T11:00:00+09:00'", 2022),
("'2016-04-13T11:00:00+09:00'", 2032),
("'2016-04-13T11:00:00+09:00'", 3002),
("'2016-04-13T11:00:00+09:00'", 4002),
("'2016-04-13T11:00:00+09:00'", 4052),
("'2016-04-13T11:00:00+09:00'", 5002),
("'2016-06-08T11:00:00+09:00'", 1002),
("'2016-06-08T11:00:00+09:00'", 1032),
("'2016-06-08T11:00:00+09:00'", 1062),
("'2016-06-08T11:00:00+09:00'", 1132),
("'2016-06-08T11:00:00+09:00'", 1172),
("'2016-06-08T11:00:00+09:00'", 2012),
("'2016-06-08T11:00:00+09:00'", 4012),
("'2016-06-08T11:00:00+09:00'", 4022),
("'2016-06-08T11:00:00+09:00'", 5012),
];
const HEADER: &str = "Version: 1\n\
IsBigEndian: True\n\
SupportPaths: False\n\
HasReferenceNodes: False\n\
root:\n";
fn build_map_first_appear() -> Byml {
Byml::Array(
MAP_FIRST_APPEAR
.iter()
.map(|(date, id)| {
let mut map = roead::byml::Map::default();
map.insert("Date".into(), Byml::String((*date).into()));
map.insert("MapID".into(), Byml::I32(*id));
Byml::Map(map)
})
.collect(),
)
}
fn build_rule_first_appear() -> Byml {
Byml::Array(
RULE_FIRST_APPEAR
.iter()
.map(|(date, rule)| {
let mut map = roead::byml::Map::default();
map.insert("Date".into(), Byml::String((*date).into()));
map.insert("GachiRule".into(), Byml::String((*rule).into()));
Byml::Map(map)
})
.collect(),
)
}
fn build_weapon_unlock() -> Byml {
Byml::Array(
WEAPON_UNLOCK
.iter()
.map(|(date, id)| {
let mut map = roead::byml::Map::default();
map.insert("Date".into(), Byml::String((*date).into()));
map.insert("WeaponSetID".into(), Byml::I32(*id));
Byml::Map(map)
})
.collect(),
)
}
fn build_byml(schedule: &Schedule, hash_kick: bool) -> Byml {
let mut root = roead::byml::Map::default();
root.insert("AddFirstMatchingTime".into(), Byml::I32(30));
root.insert("AddMatchingTime".into(), Byml::I32(0));
root.insert(
"AfterFesBonusStart".into(),
Byml::String("2016-07-24T19:00:00+09:00".into()),
);
root.insert("BottleneckThreasholdFrame".into(), Byml::I32(480));
let aligned = crate::default_timestamp();
let date_time = chrono::DateTime::from_timestamp(aligned, 0)
.unwrap()
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
root.insert("DateTime".into(), Byml::String(date_time.into()));
root.insert("DisconnectByMemoryHash".into(), Byml::Bool(hash_kick));
root.insert("TimeoutAfterJoin".into(), Byml::I32(120));
root.insert("Version".into(), Byml::I32(3));
root.insert("WaitMatchingTime".into(), Byml::I32(25));
root.insert("WebPost".into(), Byml::Bool(true));
root.insert("MapFirstAppear".into(), build_map_first_appear());
root.insert("RuleFirstAppear".into(), build_rule_first_appear());
root.insert("WeaponUnlock".into(), build_weapon_unlock());
let phases_array: Vec<Byml> = schedule
.phases
.iter()
.map(|phase| {
let mut dict = roead::byml::Map::default();
let duration_hours = ((phase.end - phase.start) / 3600) as i32;
dict.insert("Time".into(), Byml::I32(duration_hours));
let regular_stages: Vec<Byml> = phase
.regular
.iter()
.map(|&m| {
let mut stage_map = roead::byml::Map::default();
stage_map.insert("MapID".into(), Byml::I32(m));
Byml::Map(stage_map)
})
.collect();
dict.insert("RegularStages".into(), Byml::Array(regular_stages));
let gachi_stages: Vec<Byml> = phase
.gachi
.iter()
.map(|&m| {
let mut stage_map = roead::byml::Map::default();
stage_map.insert("MapID".into(), Byml::I32(m));
Byml::Map(stage_map)
})
.collect();
dict.insert("GachiStages".into(), Byml::Array(gachi_stages));
dict.insert(
"RegularRule".into(),
Byml::String(phase.regular_rule.clone().into()),
);
dict.insert(
"GachiRule".into(),
Byml::String(phase.gachi_rule.clone().into()),
);
Byml::Map(dict)
})
.collect();
root.insert("Phases".into(), Byml::Array(phases_array));
Byml::Map(root)
}
// Export the BYAML file
pub fn export(
schedule: &Schedule,
path: &str,
hash_kick: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let byml = build_byml(schedule, hash_kick);
// Version 1 is being used on console
let bytes = byml.to_binary_with_version(Endian::Big, 1);
std::fs::write(path, bytes)?;
Ok(())
}
// Export the human-readable YAML file
pub fn export_text(
schedule: &Schedule,
path: &str,
hash_kick: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let body = fix_yaml(&build_byml(schedule, hash_kick)).to_text();
let mut out = String::from(HEADER);
for line in body.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
std::fs::write(path, out)?;
Ok(())
}

62
src/generator.rs Normal file
View file

@ -0,0 +1,62 @@
use rand::Rng;
use rand::seq::SliceRandom;
use crate::schedule::{Rotation, Schedule};
// Splatoon Map IDs
const MAP_IDS: &[i32] = &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
// Ranked modes
const RULES: &[&str] = &["cVar", "cVlf", "cVgl"];
// Duration of a rotation
const PHASE_DURATION: i64 = 2 * 60 * 60;
// Generates the rotation schedule
pub fn generate(count: usize, start_time: i64) -> Schedule {
let mut rng = rand::rng();
let mut phases = Vec::with_capacity(count);
let mut prev_regular: Vec<i32> = Vec::new();
let mut prev_gachi: Vec<i32> = Vec::new();
for i in 0..count {
let start = start_time + (i as i64 * PHASE_DURATION);
let end = start + PHASE_DURATION;
let regular = pick_maps(&mut rng, MAP_IDS, &prev_regular);
let gachi = pick_maps(&mut rng, MAP_IDS, &prev_gachi);
let gachi_rule = RULES[i % RULES.len()].to_string();
prev_regular = regular.clone();
prev_gachi = gachi.clone();
phases.push(Rotation {
start,
end,
regular,
gachi,
regular_rule: "cPnt".to_string(),
gachi_rule,
});
}
Schedule { phases }
}
// Pick two maps from the pool, avoid any maps present in the previous rotation
fn pick_maps<R: Rng>(rng: &mut R, pool: &[i32], prev: &[i32]) -> Vec<i32> {
let filtered: Vec<i32> = pool
.iter()
.filter(|&&m| !prev.contains(&m))
.copied()
.collect();
let source = if filtered.len() >= 2 {
filtered
} else {
pool.to_vec()
};
let mut shuffled = source;
shuffled.shuffle(rng);
shuffled.into_iter().take(2).collect()
}

79
src/main.rs Normal file
View file

@ -0,0 +1,79 @@
use chrono::Utc;
use clap::Parser;
use std::path::Path;
mod exporter;
mod generator;
mod schedule;
// 3 weeks by default
const DEFAULT: usize = 21 * 24 / 2;
// If no start time is specified, use the last even hour
pub fn default_timestamp() -> i64 {
let now = Utc::now().timestamp();
let block = 2 * 60 * 60;
now - (now % block)
}
#[derive(Parser, Debug)]
#[command(name = "ikamaki")]
struct Args {
// How many rotations to generate
#[arg(short, long)]
count: Option<usize>,
// UNIX timestamp to start the schedule from
#[arg(short, long)]
start: Option<i64>,
// Output file path
#[arg(short, long, default_value = "VSSetting.byaml")]
output: String,
// Generate YAML in addition to the BYAML
#[arg(long)]
yml: bool,
// Set DisconnectByMemoryHash to true
#[arg(long)]
hash_kick: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
let count = args.count.unwrap_or(DEFAULT);
let start_time = args.start.unwrap_or_else(default_timestamp);
let schedule = generator::generate(count, start_time);
exporter::export(&schedule, &args.output, args.hash_kick)?;
if args.yml {
let yml_path = derive_yml_path(&args.output);
exporter::export_text(&schedule, &yml_path, args.hash_kick)?;
println!(
"Successfully generated {} rotation(s) -> {} + {}",
count, args.output, yml_path
);
} else {
println!(
"Successfully generated {} rotation(s) -> {}",
count, args.output
);
}
Ok(())
}
fn derive_yml_path(output: &str) -> String {
let path = Path::new(output);
if let Some(stem) = path.file_stem() {
if let Some(parent) = path.parent() {
return parent
.join(format!("{}.yml", stem.to_string_lossy()))
.to_string_lossy()
.into_owned();
}
return format!("{}.yml", stem.to_string_lossy());
}
format!("{}.yml", output)
}

18
src/schedule.rs Normal file
View file

@ -0,0 +1,18 @@
pub struct Rotation {
// UNIX timestamp for the start
pub start: i64,
// UNIX timestamp for the end
pub end: i64,
// Map IDs for Turf
pub regular: Vec<i32>,
// Map IDs for Ranked
pub gachi: Vec<i32>,
// Regular isn't hardcoded to Turf War for some reason
pub regular_rule: String,
// Ranked Mode (cVar, cVlf, cVgl)
pub gachi_rule: String,
}
pub struct Schedule {
pub phases: Vec<Rotation>,
}