From 659de4680dc7c5d5e2cc07cb12c90af4db68cd63 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 30 Sep 2026 02:59:14 +0800 Subject: [PATCH 01/11] wip: chart-stats Gekisou skill formation search (window replay) and measurement --- src/bin/ournotes-deck.rs | 74 ++- src/chartstats.rs | 122 +++- src/chartstats/gekisou_skills.rs | 964 +++++++++++++++++++++++++++++++ src/live/full/mod.rs | 2 + src/live/full/scorecalc.rs | 18 + src/live/full/window.rs | 224 +++++++ src/master.rs | 6 +- 7 files changed, 1393 insertions(+), 17 deletions(-) create mode 100644 src/chartstats/gekisou_skills.rs create mode 100644 src/live/full/window.rs diff --git a/src/bin/ournotes-deck.rs b/src/bin/ournotes-deck.rs index ceadc73..0e48bcd 100644 --- a/src/bin/ournotes-deck.rs +++ b/src/bin/ournotes-deck.rs @@ -18,7 +18,8 @@ const USAGE: &str = "usage: ournotes-deck skip --data FILE --roster FILE --score ID [common options] ournotes-deck live --data FILE --roster FILE --score ID [--exclude-snap-skills] [--play FILE] [--event] [--gekisou (--seeds N | --seed-list S[,S...])] [common options] - ournotes-deck chart-stats --data FILE [--seeds N] [-o FILE] + ournotes-deck chart-stats --data FILE [--seeds N] [--formation-seeds N | --no-gekisou-skills] + [--charts ID[,ID...]] [--jobs N] [-o FILE] --data is a deck data file (nnnotes.deck-data/1). live scores the whole-live simulation with snap skills, where --play is a judgement stream; with --exclude-snap-skills it scores live skills only, where --play is a per-note play. --play defaults to the theoretical best play. --gekisou plays the live with Gekisou on and ranks by the sum @@ -30,9 +31,11 @@ conditional items also require --resource-type ID --resource-id ID and context.e scenario options: --scenario free|mission|battle|arena|challenge --scenario-music ID --context FILE --scenario-music is the special row ID for arena/challenge; --score always denotes the base chart. --context uses explicit powerSnapshot.eventIds and separate resultClock normalized DateTime ticks. -chart-stats measures every chart on the whole-live simulation (ournotes-deck.chart-stats/2): the no-skill score and +chart-stats measures every chart on the whole-live simulation (ournotes-deck.chart-stats/3): the no-skill score and the weight of every score-up kind at every position, with Gekisou on per seed (--seeds N for charts with a luck range, -default 8; rank 1, range weights for the other ranks, the Perfect play's scores) and with Gekisou off (offSeeds). +default 8; rank 1, range weights for the other ranks, the Perfect play's scores) and with Gekisou off (offSeeds); and +each chart's best Gekisou skill formation measured on --formation-seeds N seeds of a luck chart (default 32, the +first --seeds of them the chart's seeds). --charts keeps only these score ids; --jobs N measures N charts at once. common options: -k N (default 10), --leader ID, --include ID[,ID...], --exclude ID[,ID...], --exclude-snaps ID[,ID...], --no-snaps, --time-limit-ms N"; @@ -46,6 +49,8 @@ fn read(path: &str) -> Result { fn chart_stats(args: &[String]) -> Result, String> { let (mut data, mut seeds, mut out) = (None, None, None); + let mut options = ournotes_deck::chartstats::Options::default(); + let (mut only, mut jobs): (Option>, usize) = (None, 1); let mut i = 0; while i < args.len() { let a = args[i].as_str(); @@ -56,14 +61,31 @@ fn chart_stats(args: &[String]) -> Result, String> { match a { "--data" => data = Some(val()?), "--seeds" => seeds = Some(val()?.trim().parse::().map_err(|_| "bad --seeds".to_string())?), + "--formation-seeds" => { + let n = val()?.trim().parse::().map_err(|_| "bad --formation-seeds".to_string())?; + options.formation_seeds = Some(n); + } + "--no-gekisou-skills" => options.formation_seeds = None, + "--charts" => only = Some(ids(&val()?)?), + "--jobs" => jobs = val()?.trim().parse::().map_err(|_| "bad --jobs".to_string())?.max(1), "-o" | "--out" => out = Some(val()?), "-h" | "--help" => return Err(USAGE.into()), other => return Err(format!("unknown option {other}\n{USAGE}")), } i += 1; } - let data = DeckData::from_path(data.ok_or("--data is required")?).map_err(|e| e.to_string())?; - let doc = ournotes_deck::chartstats::document(&data, seeds).map_err(|e| e.to_string())?; + let mut data = DeckData::from_path(data.ok_or("--data is required")?).map_err(|e| e.to_string())?; + if let Some(only) = &only { + data.charts.retain(|c| only.contains(&c.score_id)); + } + if let Some(n) = seeds { + options.seeds = n; + } + let doc = if jobs <= 1 { + ournotes_deck::chartstats::document_with(&data, &options).map_err(|e| e.to_string())? + } else { + chart_stats_parallel(data, &options, jobs)? + }; match out { Some(path) => { let mut text = serde_json::to_string(&doc).expect("json"); @@ -75,6 +97,48 @@ fn chart_stats(args: &[String]) -> Result, String> { } } +/// The chart-stats document with `jobs` charts measured at once (the same document as one at a time). +fn chart_stats_parallel( + mut data: DeckData, + options: &ournotes_deck::chartstats::Options, + jobs: usize, +) -> Result { + use ournotes_deck::chartstats; + let charts = std::mem::take(&mut data.charts); + let mut doc = chartstats::document_with(&data, options).map_err(|e| e.to_string())?; + let kinds = chartstats::kinds(&data.master); + let next = std::sync::atomic::AtomicUsize::new(0); + let results: Vec>>> = + charts.iter().map(|_| std::sync::Mutex::new(None)).collect(); + std::thread::scope(|scope| { + for _ in 0..jobs.min(charts.len().max(1)) { + scope.spawn(|| { + loop { + let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let Some(c) = charts.get(i) else { break }; + let t = std::time::Instant::now(); + let r = chartstats::chart_stats_with(&data.master, c, &kinds, options) + .map_err(|e| format!("chart {}: {e}", c.score_id)); + eprintln!( + "chart {} {:.1} s{}", + c.score_id, + t.elapsed().as_secs_f64(), + if r.is_err() { " FAILED" } else { "" } + ); + *results[i].lock().expect("lock") = Some(r); + } + }); + } + }); + let mut out = Vec::with_capacity(charts.len()); + for r in results { + let r = r.into_inner().expect("lock").ok_or("a chart was not measured")?; + out.push(serde_json::to_value(r?).expect("json")); + } + doc["charts"] = serde_json::Value::Array(out); + Ok(doc) +} + fn run(args: &[String]) -> Result { let cmd = args.first().ok_or(USAGE)?.as_str(); if cmd == "chart-stats" { diff --git a/src/chartstats.rs b/src/chartstats.rs index e105f8f..4122eff 100644 --- a/src/chartstats.rs +++ b/src/chartstats.rs @@ -47,6 +47,13 @@ use serde::Serialize; +mod gekisou_skills; + +pub use gekisou_skills::{ + Catalog, CatalogMember, CatalogSnap, FormationSlot, GekisouHeader, GekisouRangeResult, GekisouSeed, GekisouStats, + SCREEN_SEEDS, SkillLevel, plain_kind, +}; + use crate::data::{DataChart, DeckData}; use crate::error::Error; use crate::live::full::{self, GekisouSetup, LiveNote, LiveParams, LivePlay, Performer}; @@ -60,13 +67,15 @@ use crate::num::ceil_to_i32; use crate::scenario::Scenario; /// Output format name. -pub const FORMAT: &str = "ournotes-deck.chart-stats/2"; +pub const FORMAT: &str = "ournotes-deck.chart-stats/3"; /// Deck power of the measurements (a power range of real decks). pub const POWER: i32 = 300_000; /// Deck power of the check deck. pub const CHECK_POWER: i32 = 1_000_003; /// Default number of seeds when a chart has a luck range. pub const GEKISOU_SEEDS: usize = 8; +/// Default number of seeds of a luck chart's best formation (`charts[].gekisou.seeds`). +pub const FORMATION_SEEDS: usize = 32; /// The effect value of factor 1 (`value / 10000`). pub const UNIT_VALUE: i64 = 10000; /// The most fevers a live can play: the game keeps three Gekisou ranges (`LiveMusicScore.GetGekisouRanges` @@ -282,6 +291,8 @@ pub struct RangeResult { pub rank_bonus: i32, pub max_combo: i32, pub just_count: i32, + /// Luck points gained (`TotalBonusPoint`, the luck mission's rank figure). + pub luck_points: i32, /// Lottery results drawn: Miss, Hit, Super Hit, Critical. pub lot_results: [i32; 4], /// The range score on the Perfect play (every Just judged Perfect). @@ -430,6 +441,26 @@ pub struct ChartStats { /// Why the game cannot play the chart with Gekisou (Gekisou off plays it). #[serde(skip_serializing_if = "Option::is_none")] pub unplayable: Option, + /// The best Gekisou skill formation for the default scenario and its measurements ([`GekisouStats`]); `None` when + /// the chart is unplayable with Gekisou, has no Gekisou range, the master has no Gekisou skill or the statistics + /// leave the Gekisou skills out. + pub gekisou: Option, +} + +/// What the chart statistics measure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Options { + /// The size of the seed set of a chart with a luck range. + pub seeds: usize, + /// The size of the seed set of a luck chart's best formation (at least `seeds`; its first seeds are the chart's); + /// `None`: no Gekisou skills. + pub formation_seeds: Option, +} + +impl Default for Options { + fn default() -> Options { + Options { seeds: GEKISOU_SEEDS, formation_seeds: Some(FORMATION_SEEDS) } + } } /// The song and difficulty of a score id. @@ -549,8 +580,27 @@ impl Live<'_> { seed: i32, ranks: Option<&[(i32, i64)]>, ) -> Result<(i32, Vec), Error> { - let deck: Vec = - skills.iter().map(|s| Performer { live_skill: s.map(|id| (id, 1)), ..Default::default() }).collect(); + self.run_deck(play, master, &[], skills, power, seed, ranks) + } + + /// [`Live::run_play`] with these performers (a Gekisou formation; empty: none), each position's live skill set. + #[allow(clippy::too_many_arguments)] + fn run_deck( + &self, + play: &LivePlay, + master: &Master, + formation: &[Performer], + skills: &[Option], + power: i32, + seed: i32, + ranks: Option<&[(i32, i64)]>, + ) -> Result<(i32, Vec), Error> { + let deck: Vec = (0..skills.len().max(formation.len())) + .map(|k| Performer { + live_skill: skills.get(k).copied().flatten().map(|id| (id, 1)), + ..formation.get(k).cloned().unwrap_or_default() + }) + .collect(); let params = LiveParams { total_power: power, ..self.params }; let mut play = play.clone(); play.base_seed = seed; @@ -603,7 +653,25 @@ impl Live<'_> { floors: f64, slack: f64, ) -> Result { - let (exact, _) = self.run(master, &check_skills(deck), CHECK_POWER, seed, ranks)?; + self.check_with(kinds, master, &[], deck, seed, ranks, base, weight, floors, slack) + } + + /// [`Live::check`] with a Gekisou formation. + #[allow(clippy::too_many_arguments)] + fn check_with( + &self, + kinds: &[Kind], + master: &Master, + formation: &[Performer], + deck: &[Option<(usize, i64)>], + seed: i32, + ranks: Option<&[(i32, i64)]>, + base: f64, + weight: impl Fn(usize, usize) -> f64, + floors: f64, + slack: f64, + ) -> Result { + let (exact, _) = self.run_deck(&self.play, master, formation, &check_skills(deck), CHECK_POWER, seed, ranks)?; let scale = CHECK_POWER as f64 / POWER as f64; let mut per_power = base; let mut gain = 0f64; @@ -649,6 +717,7 @@ impl Live<'_> { rank_bonus: r.rank_bonus.unwrap_or(0), max_combo: r.max_combo, just_count: r.just_count, + luck_points: r.luck_points, lot_results: r.lot_results, range_score_perfect: p.end_score.wrapping_sub(p.start_score), }) @@ -743,8 +812,20 @@ impl Live<'_> { } } -/// The statistics of one chart for these kinds; `seeds` is the size of the seed set when a range is a luck range. +/// The statistics of one chart for these kinds; `seeds` is the size of the seed set when a range is a luck range; the +/// best Gekisou skill formation on [`FORMATION_SEEDS`] seeds. pub fn chart_stats(master: &Master, chart: &DataChart, kinds: &[Kind], seeds: usize) -> Result { + chart_stats_with(master, chart, kinds, &Options { seeds, ..Options::default() }) +} + +/// The statistics of one chart for these kinds with these options. +pub fn chart_stats_with( + master: &Master, + chart: &DataChart, + kinds: &[Kind], + options: &Options, +) -> Result { + let seeds = options.seeds; let settings = LiveScoreSettings::from_master(master)?; let c: Chart = chart.chart(&settings)?; let row = master @@ -805,6 +886,7 @@ pub fn chart_stats(master: &Master, chart: &DataChart, kinds: &[Kind], seeds: us seeds: Vec::new(), off_seeds: Vec::new(), unplayable: None, + gekisou: None, }; let notes: Vec = c .notes @@ -881,20 +963,40 @@ pub fn chart_stats(master: &Master, chart: &DataChart, kinds: &[Kind], seeds: us let s = live.seed_stats(kinds, &out.ranges, linear, &reads_rank, seed, &mut rng, &mut rank_rng, judged)?; out.seeds.push(s); } + + // the best Gekisou skill formation + if let Some(n) = options.formation_seeds { + let catalog = Catalog::new(master); + if catalog.has_skills() && !out.ranges.is_empty() { + let gk_seeds = if luck { published_seeds(n.max(seeds).max(1)) } else { vec![0] }; + let inputs = gekisou_skills::GkInputs { + seeds: &out.seeds, + gk_seeds: &gk_seeds, + linear, + score_id: chart.score_id, + judged, + }; + out.gekisou = Some(gekisou_skills::gekisou_stats(&live, kinds, &out.ranges, &catalog, &inputs)?); + } + } Ok(out) } -/// The statistics of every chart of a deck data file (score id order) as the `ournotes-deck.chart-stats/2` document; +/// The statistics of every chart of a deck data file (score id order) as the `ournotes-deck.chart-stats/3` document; /// `seeds` is the size of the seed set of charts with a luck range (default [`GEKISOU_SEEDS`]). pub fn document(data: &DeckData, seeds: Option) -> Result { - let seeds = seeds.unwrap_or(GEKISOU_SEEDS); - if seeds == 0 { + document_with(data, &Options { seeds: seeds.unwrap_or(GEKISOU_SEEDS), ..Options::default() }) +} + +/// [`document`] with these options. +pub fn document_with(data: &DeckData, options: &Options) -> Result { + if options.seeds == 0 || options.formation_seeds == Some(0) { return Err(Error::Input("an empty seed set".into())); } let kinds = kinds(&data.master); let mut charts = Vec::with_capacity(data.charts.len()); for c in &data.charts { - charts.push(chart_stats(&data.master, c, &kinds, seeds).map_err(|e| match e { + charts.push(chart_stats_with(&data.master, c, &kinds, options).map_err(|e| match e { Error::Domain(m) => Error::Domain(format!("chart {}: {m}", c.score_id)), e => e, })?); @@ -927,7 +1029,9 @@ pub fn document(data: &DeckData, seeds: Option) -> Result, + /// The Gekisou skill's mission (0 without one). + pub mission: i64, + performer: Performer, +} + +/// A snap of the catalog. +#[derive(Clone, Debug)] +pub struct CatalogSnap { + pub id: i64, + /// The first Gekisou support skill at the level of the snap's highest rank; `None` without one. + pub support: Option, + pub mission: i64, + /// Whether every effect of the support skill at that level is of [`ZERO_SUPPORT_TYPES`]. + pub zero: bool, +} + +/// The member cards and snaps of a master, with the Gekisou (support) skills a formation takes. +#[derive(Clone, Debug, Default)] +pub struct Catalog { + pub members: Vec, + pub snaps: Vec, +} + +fn max_level(rows: &[GekisouSkillEffectRow], id: i64) -> Option { + if id == 0 { + return None; + } + rows.iter().filter(|r| r.skill_id == id).map(|r| r.level).max() +} + +impl Catalog { + pub fn new(master: &Master) -> Catalog { + let mut members = Vec::with_capacity(master.member_cards.len()); + for c in &master.member_cards { + let band_id = master.character(c.character_id).map_or(0, |ch| ch.band_id); + let skill = max_level(&master.gekisou_skill_effects, c.gekisou_skill_id) + .map(|level| SkillLevel { id: c.gekisou_skill_id, level }); + let row = master.gekisou_skill(c.gekisou_skill_id); + let mission = if skill.is_some() { row.map_or(0, |r| r.gekisou_mission_type) } else { 0 }; + let performer = Performer { + band_id, + character_id: c.character_id, + card_type: c.card_type, + tag_ids: c.best_music_tag_ids.clone(), + live_skill_categories: master + .live_skill(c.live_skill_id) + .map(|s| s.skill_categories.clone()) + .unwrap_or_default(), + gekisou_skill_categories: row.map(|s| s.skill_categories.clone()).unwrap_or_default(), + gekisou_mission_type: mission, + gekisou_skill: skill.map(|s| (s.id, s.level)), + ..Default::default() + }; + members.push(CatalogMember { id: c.id, character_id: c.character_id, band_id, skill, mission, performer }); + } + let mut snaps = Vec::with_capacity(master.support_cards.len()); + for s in &master.support_cards { + let id = s.gekisou_support_skill_id_01; + let level = master + .support_card_ranks + .iter() + .filter(|r| r.group == s.rank_group) + .max_by_key(|r| r.rank) + .map_or(0, |r| r.gekisou_support_skill_01_level); + let rows: Vec<&GekisouSkillEffectRow> = master + .gekisou_support_skill_effects + .iter() + .filter(|r| id != 0 && r.skill_id == id && r.level == level) + .collect(); + let support = (!rows.is_empty()).then_some(SkillLevel { id, level }); + let mission = if support.is_some() { + master.gekisou_support_skill(id).map_or(0, |r| r.gekisou_mission_type) + } else { + 0 + }; + let zero = rows.iter().all(|r| ZERO_SUPPORT_TYPES.contains(&r.skill_effect_type)); + snaps.push(CatalogSnap { id: s.id, support, mission, zero }); + } + Catalog { members, snaps } + } + + /// Whether some member card has a Gekisou skill. + pub fn has_skills(&self) -> bool { + self.members.iter().any(|m| m.skill.is_some()) + } +} + +/// Whether a support skill's member target conditions (5000) hold for its member: `None` without such a condition. +fn band_match(master: &Master, support: SkillLevel, p: &Performer) -> Option { + let mut found: Option = None; + let rows = + master.gekisou_support_skill_effects.iter().filter(|r| r.skill_id == support.id && r.level == support.level); + for r in rows { + for g in [r.skill_trigger_condition_group, r.skill_condition_group] { + if g == 0 { + continue; + } + for s in master.skill_condition_sets.iter().filter(|s| s.group == g) { + for &cid in &s.condition_ids { + let Some(c) = master.skill_condition(cid) else { continue }; + if c.condition_type != CONDITION_MEMBER_TARGET { + continue; + } + let hit = c + .condition_target_ids + .iter() + .filter_map(|&t| master.skill_target(t)) + .any(|t| p.matches_skill_target(t)); + let ok = hit == c.is_positive; + found = Some(found.unwrap_or(false) || ok); + } + } + } + } + found +} + +/// One performer of a formation. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FormationSlot { + pub member_card_id: i64, + pub character_id: i64, + pub band_id: i64, + pub gekisou_skill: SkillLevel, + pub snap_id: Option, + pub support_skill: Option, + /// Whether the support skill's band condition (5000) holds for the member; `None` without a support skill or + /// without such a condition. + pub band_match: Option, +} + +/// A range of a seed with the formation. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GekisouRangeResult { + pub range_score: i32, + pub rank_bonus: i32, + pub range_score_perfect: i32, + pub max_combo: i32, + pub just_count: i32, + pub luck_points: i32, + pub lot_results: [i32; 4], +} + +/// A seed of the best formation, shaped as a no-skill seed ([`SeedStats`]): the whole-live simulation with the +/// formation; only the plain kind has weights. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GekisouSeed { + pub seed: i32, + /// The exact score with the formation and no live skill, rank 1 bonuses included. + pub score: i32, + /// The same on the Perfect play. + pub score_perfect: i32, + pub ranges: Vec, + /// `weights[kind][position]` with the formation, the plain kind only (every other kind `None`). + pub weights: Vec>>, + /// `rangeWeights[kind][position][range]`, the plain kind only; `None` when the chart's no-skill seeds have none. + pub range_weights: Option>>>>, + /// A random deck of the plain kind's master values with the formation at [`super::CHECK_POWER`]. + pub check: Check, + /// The check deck at random ranks; `None` without range weights or without ranges. + pub rank_check: Option, +} + +/// The Gekisou skill statistics of a chart: its best formation for the default scenario and the formation's seeds. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GekisouStats { + pub formation: Vec, + /// The formation's gain of the default figure on the whole-live simulation: the mean over the seeds of + /// `score / power + sum_k weights[plain][k]` with the formation minus the same without it. + pub objective: f64, + /// The same gain on the Gekisou windows of the screened seeds, as the search saw it. + pub screened: f64, + /// Formations the search evaluated. + pub evaluations: usize, + pub seeds: Vec, +} + +/// The document header of the Gekisou skills (`gekisouSkills`). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GekisouHeader { + pub plain_kind: Option, + pub member_level: &'static str, + pub snap_level: &'static str, + pub search: &'static str, + pub screen_seeds: usize, + /// Seeds of a luck chart's formation (`charts[].gekisou.seeds`); the first ones are the chart's seeds. + pub seeds: usize, +} + +impl GekisouHeader { + pub fn new(kinds: &[Kind], seeds: usize) -> GekisouHeader { + GekisouHeader { + plain_kind: plain_kind(kinds), + member_level: "highest", + snap_level: "highestRank", + search: SEARCH, + screen_seeds: SCREEN_SEEDS, + seeds, + } + } +} + +/// The plain kind: effect type 2000 on the whole deck for 5 s without targets, conditions or limits (the page's +/// `plainKind`). +pub fn plain_kind(kinds: &[Kind]) -> Option { + kinds + .iter() + .find(|k| { + k.effect_type == 2000 + && k.skill_target_ids.is_empty() + && k.skill_condition_group == 0 + && k.skill_release_condition_group == 0 + && k.effect_limit_count == 0 + && k.effect_execute_limit_count == 0 + && k.duration_ms == 5000 + }) + .map(|k| k.id) +} + +struct Info<'a>(&'a LiveModel); + +impl GekisouComboInfo for Info<'_> { + fn gekisou_combo(&self, t: i32) -> Option { + self.0.gekisou_combo_at(t) + } +} + +/// A candidate performer: a member class (`class` into the classes), a snap class (`None`: no snap) and the band +/// condition's result, with the member cards (catalog indices) that give it. +#[derive(Clone, Debug)] +struct Opt { + class: usize, + snap: Option, + cards: Vec, +} + +/// A snap class: a support skill and level, the snaps (catalog indices) that have it. +#[derive(Clone, Debug)] +struct SnapClass { + support: SkillLevel, + snaps: Vec, +} + +/// A screened seed: the no-formation window, range scores and rank bonuses. +struct Base { + seed: i32, + window: Vec, + range_scores: Vec, + rank_bonuses: Vec, +} + +/// The search of a chart. +struct Search<'a, 'm> { + live: &'a Live<'m>, + catalog: &'a Catalog, + infos: &'a [RangeInfo], + frames: Vec, + notes: Vec, + /// Bit i: the note is in range i's score frames. + in_range: Vec, + /// Per position: the window notes the plain skill covers there. + cover: Vec>, + snaps: Vec, + opts: Vec, + base: Vec, + cache: HashMap, f64>, + evaluations: usize, +} + +impl<'a, 'm> Search<'a, 'm> { + fn new( + live: &'a Live<'m>, + catalog: &'a Catalog, + infos: &'a [RangeInfo], + plain: Option<&Kind>, + seeds: &[SeedStats], + screen: usize, + ) -> Result, Error> { + let g = live.gekisou.as_ref().ok_or_else(|| Error::Input("Gekisou skills without Gekisou".into()))?; + let missions: Vec = infos.iter().map(|r| r.mission).collect(); + let mut lm = LiveModel::new_gekisou(live.master, &[], live.notes, &[], live.params, &g.setup)?; + let rf = lm.record_range_frames(&live.play, &g.dt)?; + let mut frames: Vec = rf.iter().flat_map(|r| r.start..=r.finish).collect(); + frames.sort_unstable(); + frames.dedup(); + let by_id: HashMap = live.notes.iter().map(|n| (n.note_id, n)).collect(); + let note = |id: i32| by_id.get(&id).copied().ok_or_else(|| Error::Input(format!("unknown note {id}"))); + let mut times: Vec = Vec::new(); + for f in &live.play.frames { + for j in &f.judged { + times.push(note(j.note_id)?.time_ms); + } + } + times.sort_unstable(); + let mut notes = Vec::new(); + for &fi in &frames { + for j in &live.play.frames[fi].judged { + let n = *note(j.note_id)?; + let combo = times.partition_point(|&t| t < n.time_ms) as i32; + notes.push(WindowNote { note: n, combo, score_type: 0 }); + } + } + let in_range = notes + .iter() + .map(|n| { + let f = get_frame(n.note.time_ms); + infos.iter().enumerate().fold(0u8, |m, (i, r)| { + if get_frame(r.start_ms) < f && f <= get_frame(r.end_ms) { m | (1 << i) } else { m } + }) + }) + .collect(); + + // candidates: members of the chart's missions by skill, the rest fillers; snaps of its missions by support + // skill and level + let playing = |mission: i64| mission != 0 && (mission == 4 || missions.contains(&mission)); + let mut classes: Vec<(Option, Vec)> = vec![(None, Vec::new())]; + for (i, m) in catalog.members.iter().enumerate() { + if m.skill.is_none() { + continue; + } + let key = m.skill.filter(|_| playing(m.mission)); + match classes.iter_mut().find(|c| c.0 == key) { + Some(c) => c.1.push(i), + None => classes.push((key, vec![i])), + } + } + let mut snaps: Vec = Vec::new(); + for (i, s) in catalog.snaps.iter().enumerate() { + let Some(support) = s.support else { continue }; + if s.zero || !playing(s.mission) { + continue; + } + match snaps.iter_mut().find(|c| c.support == support) { + Some(c) => c.snaps.push(i), + None => snaps.push(SnapClass { support, snaps: vec![i] }), + } + } + let mut opts = Vec::new(); + for (ci, (_, cards)) in classes.iter().enumerate() { + if !cards.is_empty() { + opts.push(Opt { class: ci, snap: None, cards: cards.clone() }); + } + } + for (si, sc) in snaps.iter().enumerate() { + for (ci, (_, cards)) in classes.iter().enumerate() { + let mut groups: Vec<(Option, Vec)> = Vec::new(); + for &c in cards { + let b = band_match(live.master, sc.support, &catalog.members[c].performer); + match groups.iter_mut().find(|g| g.0 == b) { + Some(g) => g.1.push(c), + None => groups.push((b, vec![c])), + } + } + for (_, cards) in groups { + opts.push(Opt { class: ci, snap: Some(si), cards }); + } + } + } + let mut out = Search { + live, + catalog, + infos, + frames, + notes, + in_range, + cover: Vec::new(), + snaps, + opts, + base: Vec::new(), + cache: HashMap::new(), + evaluations: 0, + }; + if let Some(kind) = plain { + out.cover = out.plain_cover(kind)?; + } + for s in seeds.iter().take(screen.max(1)) { + let window = out.window(&[], s.seed)?; + out.base.push(Base { + seed: s.seed, + window, + range_scores: s.ranges.iter().map(|r| r.range_score as i64).collect(), + rank_bonuses: s.ranges.iter().map(|r| r.rank_bonus as i64).collect(), + }); + } + Ok(out) + } + + /// Per position, the window notes the plain skill covers there: the notes at or after a factor command it files + /// and before the command that takes it back. + fn plain_cover(&self, kind: &Kind) -> Result>, Error> { + let g = self.live.gekisou.as_ref().expect("Gekisou on"); + let id = KIND_SKILL_BASE - kind.id as i64; + let mut out = Vec::with_capacity(self.live.positions); + for k in 0..self.live.positions { + let deck: Vec = (0..self.live.positions) + .map(|i| Performer { live_skill: (i == k).then_some((id, 1)), ..Default::default() }) + .collect(); + let (notes, events, params) = (self.live.notes, self.live.events, self.live.params); + let mut lm = LiveModel::new_gekisou(self.live.measure, &deck, notes, events, params, &g.setup)?; + lm.run_timed(&self.live.play, &g.dt)?; + let owner = (k as i32) * 100 + OWNER_MEMBER; + let mut cmds: Vec = + lm.factor_commands().into_iter().filter(|c| c.owner_id == owner && c.note_mill != 0).collect(); + cmds.sort_by_key(|c| c.time_ms); + let mut spans: Vec<(i32, i32)> = Vec::new(); + let mut open: Option = None; + for c in cmds { + if c.note_mill > 0 { + open.get_or_insert(c.time_ms); + } else if let Some(s) = open.take() { + spans.push((s, c.time_ms)); + } + } + if let Some(s) = open { + spans.push((s, i32::MAX)); + } + out.push( + self.notes + .iter() + .map(|n| spans.iter().any(|&(s, e)| s <= n.note.time_ms && n.note.time_ms < e)) + .collect(), + ); + } + Ok(out) + } + + /// The window notes' scores with these performers on a seed. + fn window(&self, deck: &[Performer], seed: i32) -> Result, Error> { + let g = self.live.gekisou.as_ref().expect("Gekisou on"); + let mut lm = LiveModel::new_gekisou(self.live.master, deck, self.live.notes, &[], self.live.params, &g.setup)?; + let judged = lm.run_frames(&self.live.play, seed, &g.dt, &self.frames)?; + if judged.len() != self.notes.len() { + return Err(Error::Game("the window run judged other notes".into())); + } + let mut notes = self.notes.clone(); + for (n, &(id, j)) in notes.iter_mut().zip(&judged) { + if n.note.note_id != id { + return Err(Error::Game("the window run judged the notes in another order".into())); + } + n.score_type = convert_score_type(j as i64)?; + } + let calc = lm.initial_calculator(self.live.params.total_power); + let cmds = lm.factor_commands(); + window_note_scores(&calc, lm.score_max_frame(), ¬es, &cmds, &Info(&lm)) + } + + /// The performers of a formation state: a representative member card of each candidate with its snap's support + /// skill, at least five performers. + fn deck(&self, state: &[usize]) -> Vec { + let mut out: Vec = state + .iter() + .map(|&o| { + let opt = &self.opts[o]; + let mut p = self.catalog.members[opt.cards[0]].performer.clone(); + if let Some(s) = opt.snap { + let s = self.snaps[s].support; + p.gekisou_support_skills = vec![(s.id, s.level)]; + } + p + }) + .collect(); + while out.len() < self.live.positions.max(5) { + out.push(Performer::default()); + } + out + } + + /// A member card (catalog index) per performer of a state, five different characters (augmenting paths, cards + /// in catalog order); `None` when there is none or a snap class is used more often than it has snaps. + fn assign(&self, state: &[usize]) -> Option> { + let mut used = vec![0usize; self.snaps.len()]; + for &o in state { + if let Some(s) = self.opts[o].snap { + used[s] += 1; + if used[s] > self.snaps[s].snaps.len() { + return None; + } + } + } + fn augment( + s: &Search<'_, '_>, + state: &[usize], + j: usize, + seen: &mut Vec, + owner: &mut HashMap, + card: &mut [usize], + ) -> bool { + for &c in &s.opts[state[j]].cards { + let ch = s.catalog.members[c].character_id; + if seen.contains(&ch) { + continue; + } + seen.push(ch); + let free = match owner.get(&ch).copied() { + None => true, + Some(other) => augment(s, state, other, seen, owner, card), + }; + if free { + owner.insert(ch, j); + card[j] = c; + return true; + } + } + false + } + let mut owner: HashMap = HashMap::new(); + let mut card = vec![usize::MAX; state.len()]; + for j in 0..state.len() { + let mut seen = Vec::new(); + if !augment(self, state, j, &mut seen, &mut owner, &mut card) { + return None; + } + } + Some(card) + } + + /// The default figure's gain of a state on the screened seeds, from the windows: `(score gain) / power + sum_k + /// (weight gain of the plain skill at k)`, averaged. + fn value(&mut self, state: &[usize]) -> Result { + if let Some(&v) = self.cache.get(state) { + return Ok(v); + } + let deck = self.deck(state); + let mut total = 0f64; + for b in &self.base { + let e = match self.window(&deck, b.seed) { + Ok(e) => e, + // a skill the simulation cannot play: never chosen + Err(Error::Unsupported(_)) => { + self.cache.insert(state.to_vec(), f64::NEG_INFINITY); + return Ok(f64::NEG_INFINITY); + } + Err(e) => return Err(e), + }; + let ranges = self.infos.len(); + let mut dn = 0i64; + let mut drs = vec![0i64; ranges]; + let mut dw = vec![0f64; self.cover.len()]; + let mut drw = vec![vec![0f64; ranges]; self.cover.len()]; + for (i, (x, x0)) in e.iter().zip(&b.window).enumerate() { + let d = x.score as i64 - x0.score as i64; + let du = x.unit - x0.unit; + dn += d; + let m = self.in_range[i]; + for (r, v) in drs.iter_mut().enumerate() { + if m & (1 << r) != 0 { + *v += d; + } + } + for (k, cover) in self.cover.iter().enumerate() { + if cover[i] { + dw[k] += du; + for (r, v) in drw[k].iter_mut().enumerate() { + if m & (1 << r) != 0 { + *v += du; + } + } + } + } + } + let mut ds = dn; + for (r, info) in self.infos.iter().enumerate() { + ds += (b.range_scores[r] + drs[r]) * info.rank_bonus_percent / 100 - b.rank_bonuses[r]; + } + let mut w = 0f64; + for k in 0..dw.len() { + w += dw[k]; + for (r, info) in self.infos.iter().enumerate() { + w += info.rank_bonus_percent as f64 / 100.0 * drw[k][r]; + } + } + total += (ds as f64 + w) / POWER as f64; + } + let v = total / self.base.len().max(1) as f64; + self.evaluations += 1; + self.cache.insert(state.to_vec(), v); + Ok(v) + } + + /// The best feasible state among `candidates` (the first on ties), with its value. + fn best_of(&mut self, candidates: Vec>) -> Result, f64)>, Error> { + let mut best: Option<(Vec, f64)> = None; + for c in candidates { + if self.assign(&c).is_none() { + continue; + } + let v = self.value(&c)?; + if best.as_ref().is_none_or(|b| v > b.1 + 1e-12) { + best = Some((c, v)); + } + } + Ok(best) + } + + /// Greedy fill, then coordinate descent: the chosen state and its screened value. + fn run(&mut self) -> Result<(Vec, f64), Error> { + let mut state: Vec = Vec::new(); + let mut value = 0f64; + for _ in 0..5 { + let cands: Vec> = (0..self.opts.len()) + .map(|o| { + let mut s = state.clone(); + s.push(o); + s + }) + .collect(); + let (s, v) = + self.best_of(cands)?.ok_or_else(|| Error::Domain("no five members of different characters".into()))?; + state = s; + value = v; + } + for _ in 0..MAX_SWEEPS { + let before = value; + for j in 0..state.len() { + let cands: Vec> = (0..self.opts.len()) + .filter(|&o| o != state[j]) + .map(|o| { + let mut s = state.clone(); + s[j] = o; + s + }) + .collect(); + if let Some((s, v)) = self.best_of(cands)? + && v > value + 1e-12 + { + state = s; + value = v; + } + } + // two performers swap snaps, each keeping its member class (any band condition result it gives) + for a in 0..state.len() { + for b in a + 1..state.len() { + let (oa, ob) = (&self.opts[state[a]], &self.opts[state[b]]); + if oa.snap == ob.snap { + continue; + } + let with = |class: usize, snap: Option| -> Vec { + (0..self.opts.len()) + .filter(|&o| self.opts[o].class == class && self.opts[o].snap == snap) + .collect() + }; + let (na, nb) = (with(oa.class, ob.snap), with(ob.class, oa.snap)); + let mut cands = Vec::new(); + for &x in &na { + for &y in &nb { + let mut s = state.clone(); + s[a] = x; + s[b] = y; + cands.push(s); + } + } + if let Some((s, v)) = self.best_of(cands)? + && v > value + 1e-12 + { + state = s; + value = v; + } + } + } + if value <= before + 1e-12 { + break; + } + } + Ok((state, value)) + } +} + +/// The score at these ranks: `score - sum_i rankBonus_i + sum_i trunc(rangeScore_i * percent_i(ranks[i]) / 100)`. +fn score_at_ranks(score: i32, ranges: &[GekisouRangeResult], infos: &[RangeInfo], ranks: &[i32]) -> Result { + let mut s = score as i64; + for ((r, info), &rank) in ranges.iter().zip(infos).zip(ranks) { + s += r.range_score as i64 * info.percent(rank)? / 100 - r.rank_bonus as i64; + } + Ok(s as i32) +} + +/// The inputs of a chart's Gekisou skill statistics besides the live: the chart's no-skill seeds, the formation's +/// seeds (the chart's seeds first), whether the ranks follow linearly, the score id and the judged notes. +pub(super) struct GkInputs<'a> { + pub seeds: &'a [SeedStats], + pub gk_seeds: &'a [i32], + pub linear: bool, + pub score_id: i64, + pub judged: i32, +} + +/// The Gekisou skill statistics of a chart. +pub(super) fn gekisou_stats( + live: &Live<'_>, + kinds: &[Kind], + infos: &[RangeInfo], + catalog: &Catalog, + inp: &GkInputs<'_>, +) -> Result { + let plain = plain_kind(kinds); + let mut search = Search::new(live, catalog, infos, plain.map(|p| &kinds[p]), inp.seeds, SCREEN_SEEDS)?; + let (state, screened) = search.run()?; + let cards = search.assign(&state).ok_or_else(|| Error::Game("the chosen formation has no members".into()))?; + + // the formation: its member cards, the snaps of each class in catalog order + let mut next = vec![0usize; search.snaps.len()]; + let mut formation = Vec::with_capacity(5); + let mut performers = Vec::with_capacity(5); + for (&o, &c) in state.iter().zip(&cards) { + let opt = &search.opts[o]; + let m = &catalog.members[c]; + let skill = m.skill.ok_or_else(|| Error::Game(format!("member card {} has no Gekisou skill", m.id)))?; + let mut p = m.performer.clone(); + let (snap_id, support) = match opt.snap { + Some(s) => { + let sc = &search.snaps[s]; + let snap = &catalog.snaps[sc.snaps[next[s]]]; + next[s] += 1; + p.gekisou_support_skills = vec![(sc.support.id, sc.support.level)]; + (Some(snap.id), Some(sc.support)) + } + None => (None, None), + }; + let band = support.and_then(|s| band_match(live.master, s, &m.performer)); + formation.push(FormationSlot { + member_card_id: m.id, + character_id: m.character_id, + band_id: m.band_id, + gekisou_skill: skill, + snap_id, + support_skill: support, + band_match: band, + }); + performers.push(p); + } + let evaluations = search.evaluations; + drop(search); + + let mut rng = Rng(GK_CHECK_SALT ^ inp.score_id as u64); + let mut rank_rng = Rng(GK_RANK_SALT ^ inp.score_id as u64); + let mut out = Vec::with_capacity(inp.gk_seeds.len()); + let mut gain = 0f64; + let figure = |score: i32, w: Option<&Vec>| score as f64 / POWER as f64 + w.map_or(0.0, |w| w.iter().sum()); + for (i, &seed) in inp.gk_seeds.iter().enumerate() { + let s = measure_seed(live, kinds, infos, plain, inp, &performers, seed, &mut rng, &mut rank_rng)?; + let with = figure(s.score, plain.and_then(|p| s.weights[p].as_ref())); + let without = match inp.seeds.get(i) { + Some(b) if b.seed == seed => figure(b.score, plain.map(|p| &b.weights[p])), + _ => { + let (score, w) = no_skill_figure(live, kinds, plain, seed)?; + figure(score, w.as_ref()) + } + }; + gain += with - without; + out.push(s); + } + Ok(GekisouStats { + formation, + objective: gain / inp.gk_seeds.len().max(1) as f64, + screened, + evaluations, + seeds: out, + }) +} + +/// The no-skill score and plain weights of a seed that is not one of the chart's seeds. +fn no_skill_figure( + live: &Live<'_>, + kinds: &[Kind], + plain: Option, + seed: i32, +) -> Result<(i32, Option>), Error> { + let none = vec![None; live.positions]; + let (score, _) = live.run_deck(&live.play, live.master, &[], &none, POWER, seed, None)?; + let Some(p) = plain else { return Ok((score, None)) }; + let unit = kind_factor(kinds[p].effect_type, UNIT_VALUE); + let mut w = Vec::with_capacity(live.positions); + for k in 0..live.positions { + let mut skills = none.clone(); + skills[k] = Some(KIND_SKILL_BASE - p as i64); + let (s, _) = live.run_deck(&live.play, live.measure, &[], &skills, POWER, seed, None)?; + w.push((s as f64 - score as f64) / (POWER as f64 * unit)); + } + Ok((score, Some(w))) +} + +/// The whole-live measurements of the formation on one seed. +#[allow(clippy::too_many_arguments)] +fn measure_seed( + live: &Live<'_>, + kinds: &[Kind], + infos: &[RangeInfo], + plain: Option, + inp: &GkInputs<'_>, + formation: &[Performer], + seed: i32, + rng: &mut Rng, + rank_rng: &mut Rng, +) -> Result { + let g = live.gekisou.as_ref().ok_or_else(|| Error::Input("Gekisou skills without Gekisou".into()))?; + let none = vec![None; live.positions]; + let (score, gk) = live.run_deck(&live.play, live.master, formation, &none, POWER, seed, None)?; + let (score_perfect, gkp) = live.run_deck(&g.perfect, live.master, formation, &none, POWER, seed, None)?; + if gkp.len() != gk.len() || gk.len() != infos.len() { + return Err(Error::Game("the formation's plays have other ranges".into())); + } + let rs = |r: &GekisouRange| r.end_score.wrapping_sub(r.start_score); + let ranges: Vec = gk + .iter() + .zip(&gkp) + .map(|(r, p)| GekisouRangeResult { + range_score: rs(r), + rank_bonus: r.rank_bonus.unwrap_or(0), + range_score_perfect: rs(p), + max_combo: r.max_combo, + just_count: r.just_count, + luck_points: r.luck_points, + lot_results: r.lot_results, + }) + .collect(); + let mut weights: Vec>> = vec![None; kinds.len()]; + let mut range_weights: Vec>>> = vec![None; kinds.len()]; + if let Some(p) = plain { + let unit = kind_factor(kinds[p].effect_type, UNIT_VALUE); + let mut w = Vec::with_capacity(live.positions); + let mut rw = Vec::with_capacity(live.positions); + for k in 0..live.positions { + let mut skills = none.clone(); + skills[k] = Some(KIND_SKILL_BASE - p as i64); + let (s, gks) = live.run_deck(&live.play, live.measure, formation, &skills, POWER, seed, None)?; + w.push((s as f64 - score as f64) / (POWER as f64 * unit)); + rw.push( + gks.iter() + .zip(&ranges) + .map(|(r, r0)| (rs(r) as f64 - r0.range_score as f64) / (POWER as f64 * unit)) + .collect(), + ); + } + weights[p] = Some(w); + range_weights[p] = Some(rw); + } + let range_weights = inp.linear.then_some(range_weights); + + // the check deck: one of the plain kind's master values (or no skill) at each position, at another power + let usable: Vec = plain.into_iter().collect(); + let (deck, rows) = check_deck(kinds, &usable, live.positions, rng); + let master = Live::master_with(live.master, &rows); + let floors = inp.judged as f64 + MAX_GEKISOU_FEVERS as f64; + let weight = |ki: usize, k: usize| weights[ki].as_ref().map_or(f64::NAN, |w| w[k]); + let base = score as f64 / POWER as f64; + let c = live + .check_with(kinds, &master, formation, &deck, seed, None, base, weight, floors, 0.0)? + .within(|| format!("Gekisou skills, seed {seed}"))?; + let check = Check { deck, exact: c.exact, predicted: c.predicted, bound: c.bound }; + + let mut rank_check = None; + if let Some(rw) = range_weights.as_ref().filter(|_| !ranges.is_empty()) { + let ranks: Vec = infos.iter().map(|_| 1 + rank_rng.below(RANKS) as i32).collect(); + let confirmations: Vec<(i32, i64)> = + ranks.iter().zip(infos).map(|(&r, info)| Ok((r, info.percent(r)?))).collect::>()?; + let base = score_at_ranks(score, &ranges, infos, &ranks)? as f64 / POWER as f64; + let mut d = Vec::with_capacity(infos.len()); + for (info, &rank) in infos.iter().zip(&ranks) { + d.push((info.percent(rank)? - info.percent(1)?) as f64 / 100.0); + } + let weight = |ki: usize, k: usize| match (&weights[ki], &rw[ki]) { + (Some(w), Some(rw)) => w[k] + rw[k].iter().zip(&d).map(|(&r, &d)| r * d).sum::(), + _ => f64::NAN, + }; + // each range moves a weight by the difference of two floored bonuses from its rank 1 value: under 2 points + let slack = 2.0 * infos.len() as f64; + let c = live + .check_with( + kinds, + &master, + formation, + &check.deck, + seed, + Some(&confirmations), + base, + weight, + floors, + slack, + )? + .within(|| format!("Gekisou skills, seed {seed} at ranks {ranks:?}"))?; + rank_check = Some(RankCheck { ranks, exact: c.exact, predicted: c.predicted, bound: c.bound }); + } + Ok(GekisouSeed { seed, score, score_perfect, ranges, weights, range_weights, check, rank_check }) +} diff --git a/src/live/full/mod.rs b/src/live/full/mod.rs index 7e9321f..45e82a6 100644 --- a/src/live/full/mod.rs +++ b/src/live/full/mod.rs @@ -50,6 +50,8 @@ mod life; mod raw_runtime; pub use raw_runtime::{RELAX_TARGET_JUDGEMENTS, RawJudgedNote, RawJudgementRuntime}; mod scorecalc; +mod window; +pub use window::{NoteEval, RangeFrames, WindowNote, window_note_scores}; use std::collections::{HashMap, HashSet, VecDeque}; diff --git a/src/live/full/scorecalc.rs b/src/live/full/scorecalc.rs index 481482f..3d25635 100644 --- a/src/live/full/scorecalc.rs +++ b/src/live/full/scorecalc.rs @@ -142,6 +142,17 @@ impl IncrementalCalculator { self.factors[f].push(cmd); } + /// Every factor command filed so far, frame by frame and in filing order within a frame (the order the frames + /// apply them after sorting by time and owner, a stable sort). + pub(crate) fn factor_commands(&self) -> impl Iterator { + self.factors.iter().flatten() + } + + /// The last frame (exclusive) of the frame table: commands and notes beyond it are filed in its last frame. + pub(crate) fn max_frame(&self) -> i32 { + self.max_frame + } + /// Sets the fixed score filed by the next calculation (only the last one set counts). pub(crate) fn add_fixed(&mut self, time_ms: i32, score: i32) { self.pending_fixed = Some((time_ms, score)); @@ -231,3 +242,10 @@ impl IncrementalCalculator { Ok(()) } } + +impl IncrementalCalculator { + /// `(note id, chart time, last score)` of every note filed so far. + pub(crate) fn note_results(&self) -> Vec<(i32, i32, i32)> { + self.notes.iter().flatten().map(|n| (n.note_id, n.time_ms, n.added)).collect() + } +} diff --git a/src/live/full/window.rs b/src/live/full/window.rs new file mode 100644 index 0000000..b17bc26 --- /dev/null +++ b/src/live/full/window.rs @@ -0,0 +1,224 @@ +//! Gekisou windows: the frames of a play where Gekisou skills act, the simulation driven over those frames only, and +//! the note scores of a window recomputed from the factor commands and the Gekisou combo. +//! +//! A Gekisou (support) skill triggers only while a range of its mission is concerned (a state change of such a range +//! in the frame, or the range playing), and everything it changes ends by the range's Finish: its combo and Just +//! bonuses, cumulative rules and luck factors act on the range's own counts; its score factors end at the latest when +//! the range completes and the luck rush it raises ends at the Finish (`GekisouController.BeforeUpdate`). So a live +//! with Gekisou skills differs from the live without them only in the notes judged from a range's Start frame to its +//! Finish frame (the range's window), and the simulation can play just those frames: every random draw of the luck +//! lottery and of the probability condition 4011 happens inside them, in the same order. +//! +//! The window's note scores follow from the commands filed during the window: every frame is executed for the last +//! time after every command filed in it or earlier (a command filed in an executed frame undoes and executes again +//! from that frame, `IncrementalCalculator`), so a note's final score is the one of the time-ordered command list +//! (frames in order; in a frame factor commands by time and owner, a note after the commands of its time) with the +//! final Gekisou combo of its range (the combo is frozen once the range is past its end delay, and the range's notes +//! are scored again when it completes). The undo of a frame subtracts the float sum of the changes it applied, so the +//! game's float state can differ from the ordered sum in the last bits (see [`super::scorecalc`]); the recomputed +//! scores ignore that. + +use serde::Serialize; + +use super::gekisou::{S_COMPLETE, S_DELAY, S_END, S_FINISH, S_STANDBY, S_START}; +use super::{LiveModel, LiveNote, LivePlay}; +use crate::error::Error; +use crate::live::score::{GekisouComboInfo, LiveScoreCalculator, ScoreFactorState, get_frame, get_luck_factor_percent}; +use crate::live::skill::{FactorCommand, apply_factor}; +use crate::num::min_ignoring_nan; + +/// Frame indices (into the play's frames) where a Gekisou range changed its state. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RangeFrames { + /// Wait -> Standby (`None` when the range never waited: it started in the first frame). + pub standby: Option, + /// The fever's start: Start (the next frame is Playing). + pub start: usize, + /// The fever's end: End. + pub end: usize, + /// The end delay (the longest judgement window after the end, counted with the frame delta times): Delay. + pub delay: usize, + /// 500 ms later: Complete (the range's scores are taken and its rank bonus added). + pub complete: usize, + /// The next frame: Finish (the luck rush ends). + pub finish: usize, +} + +/// A note of a window: the chart note, the combo the score reads for it (the live's combo at its time) and its score +/// type after the judgement conversion. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WindowNote { + pub note: LiveNote, + pub combo: i32, + pub score_type: i32, +} + +/// A window note's score and its derivatives by the factors a live skill adds. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NoteEval { + pub score: i32, + /// The score per unit of note score factor (effect types 2000 / 2005, and 2004 when the score type is a target), + /// before the floors: `base * judgement% * combo factor * luck% / notes`. + pub unit: f64, + /// The score per unit of combo score factor (2002): `base * judgement% * gekisou combo factor * score factor * + /// luck% / notes`. + pub unit_combo: f64, +} + +impl LiveModel { + /// Plays every frame of `play` (delta times `dt`) and records the frames where each Gekisou range changed state. + pub fn record_range_frames(&mut self, play: &LivePlay, dt: &[f32]) -> Result, Error> { + if dt.len() != play.frames.len() { + return Err(Error::Input("one delta time per frame".into())); + } + self.random.set_seed(play.base_seed); + let n = self.gk.as_ref().map_or(0, |g| g.ctrl.ranges.len()); + let mut seen = vec![[None::; 9]; n]; + for (i, (f, &d)) in play.frames.iter().zip(dt).enumerate() { + self.frame_timed(f.time_ms, &f.judged, d)?; + let g = self.gk.as_ref().ok_or_else(|| Error::Input("range frames without Gekisou".into()))?; + for &idx in &g.ctrl.state_updates { + let s = g.ctrl.states[idx].state as usize; + if let Some(slot) = seen[idx].get_mut(s) { + slot.get_or_insert(i); + } + } + } + seen.iter() + .enumerate() + .map(|(i, s)| { + let need = |state: u8| { + s[state as usize] + .ok_or_else(|| Error::Game(format!("Gekisou range {i} never reached state {state}"))) + }; + Ok(RangeFrames { + standby: s[S_STANDBY as usize], + start: need(S_START)?, + end: need(S_END)?, + delay: need(S_DELAY)?, + complete: need(S_COMPLETE)?, + finish: need(S_FINISH)?, + }) + }) + .collect() + } + + /// Plays only the frames `frames` of `play` (indices, ascending) with their delta times and the random seed `seed` (the + /// play's own seed is not read); returns the judged notes + /// of those frames in order, `(note id, judgement after the conversion)`. + pub fn run_frames( + &mut self, + play: &LivePlay, + seed: i32, + dt: &[f32], + frames: &[usize], + ) -> Result, Error> { + if dt.len() != play.frames.len() { + return Err(Error::Input("one delta time per frame".into())); + } + if frames.windows(2).any(|w| w[1] <= w[0]) { + return Err(Error::Input("window frames must ascend".into())); + } + self.random.set_seed(seed); + let mut judged = Vec::new(); + for &i in frames { + let f = play.frames.get(i).ok_or_else(|| Error::Input(format!("window frame {i} out of range")))?; + self.frame_timed(f.time_ms, &f.judged, dt[i])?; + judged.extend(self.judged.iter().map(|&(id, j, _)| (id, j))); + } + Ok(judged) + } + + /// The Gekisou combo the score reads at a chart time (`None` outside a combo range or without Gekisou). + pub fn gekisou_combo_at(&self, time_ms: i32) -> Option { + self.gk.as_ref().and_then(|g| g.ctrl.gekisou_combo(time_ms)) + } + + /// Every factor command filed so far, frame by frame, in filing order within a frame. + pub fn factor_commands(&self) -> Vec { + self.score.factor_commands().copied().collect() + } + + /// The score calculator with the live's initial factor state (only the band total power). + pub fn initial_calculator(&self, total_power: i32) -> LiveScoreCalculator { + let mut c = self.score.calc.clone(); + c.state = ScoreFactorState::new(total_power); + c + } + + /// The last frame (exclusive) of the score's frame table. + pub fn score_max_frame(&self) -> i32 { + self.score.max_frame() + } +} + +/// The scores of a window's notes (in their order) from the factor commands filed in the window (in filing order): +/// frames in order; in a frame the commands by (time, owner), stably, each before the notes of its time and later, +/// the notes by (time, note id). `gekisou` gives the Gekisou combo; the life is full. +pub fn window_note_scores( + calc: &LiveScoreCalculator, + max_frame: i32, + notes: &[WindowNote], + commands: &[FactorCommand], + gekisou: &dyn GekisouComboInfo, +) -> Result, Error> { + let frame = |t: i32| get_frame(t).min(max_frame.wrapping_sub(1)); + // (frame, time, 0 command / 1 note, owner or note id, index) + let mut items: Vec<(i32, i32, u8, i32, usize)> = Vec::with_capacity(notes.len() + commands.len()); + for (i, c) in commands.iter().enumerate() { + items.push((frame(c.time_ms), c.time_ms, 0, c.owner_id, i)); + } + for (i, n) in notes.iter().enumerate() { + items.push((frame(n.note.time_ms), n.note.time_ms, 1, n.note.note_id, i)); + } + items.sort_by_key(|x| (x.0, x.1, x.2, x.3)); + let mut calc = calc.clone(); + let mut out = vec![NoteEval { score: 0, unit: 0.0, unit_combo: 0.0 }; notes.len()]; + for (_, _, kind, _, i) in items { + if kind == 0 { + apply_factor(&mut calc.state, &commands[i]); + continue; + } + let n = ¬es[i]; + let score = + calc.note_score(n.combo, 1000, n.note.time_ms, n.note.note_operate_type, n.score_type, Some(gekisou))?; + let (unit, unit_combo) = note_units(&calc, n, gekisou)?; + out[i] = NoteEval { score, unit, unit_combo }; + } + Ok(out) +} + +/// The score of a note per unit of note score factor and of combo score factor, before the floors. +fn note_units(calc: &LiveScoreCalculator, n: &WindowNote, gekisou: &dyn GekisouComboInfo) -> Result<(f64, f64), Error> { + let np = *calc + .note_factor_percent + .get(&n.note.note_operate_type) + .ok_or_else(|| Error::Game(format!("note type {} has no score percent", n.note.note_operate_type)))?; + let jp = *calc + .judgement_score_factor_percent + .get(&n.score_type) + .ok_or_else(|| Error::Game(format!("score type {} has no score percent", n.score_type)))?; + let t = (calc.score_adjustment_factor * calc.state.band_total_power as f32) * calc.music_difficulty_factor; + let a = (np as f32 / 100f32) * t; + let b = (jp as f32 / 100f32) * a; + let gk = calc.gekisou_combo_bonus_factor(Some(gekisou), n.note.time_ms)?; + let cum = match &calc.combo_table { + None => 0f32, + Some(tb) => tb.get_cumulative_factor(crate::live::score::COMBO, n.combo)?, + }; + let combo_factor = gk * (calc.state.combo_score_up + (min_ignoring_nan(cum, 1f32) + 1f32)); + let score_up = calc.state.note_score_up + calc.state.judgement_factor(n.score_type); + let luck = get_luck_factor_percent(calc.state.added_luck_bonus) as f64 / 100.0; + let per = f64::from(b) * luck / f64::from(calc.converted_note_count) + * f64::from(calc.event_bonus_factor) + * f64::from(calc.assist_factor); + Ok((per * f64::from(combo_factor), per * f64::from(gk) * f64::from(score_up))) +} + +impl LiveModel { + /// `(note id, chart time, last score)` of every note scored so far. + pub fn note_results(&self) -> Vec<(i32, i32, i32)> { + self.score.note_results() + } +} diff --git a/src/master.rs b/src/master.rs index 855eac5..691b602 100644 --- a/src/master.rs +++ b/src/master.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use serde::de::DeserializeOwned; -use serde::{Deserialize, Deserializer}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::error::Error; @@ -30,7 +30,7 @@ fn f32_text<'de, D: Deserializer<'de>>(d: D) -> Result { macro_rules! row { ($(#[$m:meta])* $name:ident { $($(#[$fm:meta])* $f:ident : $t:ty = $json:literal),* $(,)? }) => { $(#[$m])* - #[derive(Clone, Debug, Default, Deserialize)] + #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(default)] pub struct $name { $($(#[$fm])* #[serde(rename = $json)] pub $f: $t,)* @@ -208,7 +208,7 @@ row!( ); /// `MasterSkillCondition`. -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct SkillConditionRow { #[serde(rename = "_id", default)] pub id: i64, From 88f3dcd5ba1b9b2599d71238531c5ee2fb73b7cc Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 30 Sep 2026 03:05:27 +0800 Subject: [PATCH 02/11] wip: search probe interface for the Gekisou formation search --- src/chartstats.rs | 46 ++++++++++++++++++++- src/chartstats/gekisou_skills.rs | 69 +++++++++++++++++++++++++++++--- 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/src/chartstats.rs b/src/chartstats.rs index 4122eff..6e65529 100644 --- a/src/chartstats.rs +++ b/src/chartstats.rs @@ -51,7 +51,7 @@ mod gekisou_skills; pub use gekisou_skills::{ Catalog, CatalogMember, CatalogSnap, FormationSlot, GekisouHeader, GekisouRangeResult, GekisouSeed, GekisouStats, - SCREEN_SEEDS, SkillLevel, plain_kind, + SCREEN_SEEDS, Search, SearchOption, SkillLevel, plain_kind, }; use crate::data::{DataChart, DeckData}; @@ -824,6 +824,43 @@ pub fn chart_stats_with( chart: &DataChart, kinds: &[Kind], options: &Options, +) -> Result { + chart_stats_inner(master, chart, kinds, options, None) +} + +/// A search probe: `f` gets the chart's formation search (screened on the first [`SCREEN_SEEDS`] of `seeds` seeds) +/// instead of the chart's Gekisou skill statistics; `None` when the chart has none. Diagnostics, not a stable +/// interface. +#[doc(hidden)] +pub fn search_probe( + master: &Master, + chart: &DataChart, + kinds: &[Kind], + seeds: usize, + f: impl FnOnce(&mut Search<'_, '_>) -> Result, +) -> Result, Error> { + let mut f = Some(f); + let mut out = None; + let mut hook = |s: &mut Search<'_, '_>| -> Result<(), Error> { + if let Some(f) = f.take() { + out = Some(f(s)?); + } + Ok(()) + }; + let options = Options { seeds, formation_seeds: Some(seeds) }; + chart_stats_inner(master, chart, kinds, &options, Some(&mut hook))?; + Ok(out) +} + +/// A hook that gets a chart's formation search. +type SearchHook<'h> = &'h mut dyn FnMut(&mut Search<'_, '_>) -> Result<(), Error>; + +fn chart_stats_inner( + master: &Master, + chart: &DataChart, + kinds: &[Kind], + options: &Options, + probe: Option>, ) -> Result { let seeds = options.seeds; let settings = LiveScoreSettings::from_master(master)?; @@ -976,7 +1013,12 @@ pub fn chart_stats_with( score_id: chart.score_id, judged, }; - out.gekisou = Some(gekisou_skills::gekisou_stats(&live, kinds, &out.ranges, &catalog, &inputs)?); + match probe { + Some(hook) => hook(&mut gekisou_skills::search(&live, kinds, &out.ranges, &catalog, &out.seeds)?)?, + None => { + out.gekisou = Some(gekisou_skills::gekisou_stats(&live, kinds, &out.ranges, &catalog, &inputs)?) + } + } } } Ok(out) diff --git a/src/chartstats/gekisou_skills.rs b/src/chartstats/gekisou_skills.rs index 1571e55..50a69db 100644 --- a/src/chartstats/gekisou_skills.rs +++ b/src/chartstats/gekisou_skills.rs @@ -329,8 +329,9 @@ struct Base { rank_bonuses: Vec, } -/// The search of a chart. -struct Search<'a, 'm> { +/// The formation search of a chart. Its public methods are diagnostics ([`super::search_probe`]), not a stable +/// interface. +pub struct Search<'a, 'm> { live: &'a Live<'m>, catalog: &'a Catalog, infos: &'a [RangeInfo], @@ -341,6 +342,8 @@ struct Search<'a, 'm> { /// Per position: the window notes the plain skill covers there. cover: Vec>, snaps: Vec, + /// The member classes' Gekisou skills (`None`: the fillers). + classes: Vec>, opts: Vec, base: Vec, cache: HashMap, f64>, @@ -445,6 +448,7 @@ impl<'a, 'm> Search<'a, 'm> { in_range, cover: Vec::new(), snaps, + classes: classes.iter().map(|c| c.0).collect(), opts, base: Vec::new(), cache: HashMap::new(), @@ -547,7 +551,7 @@ impl<'a, 'm> Search<'a, 'm> { /// A member card (catalog index) per performer of a state, five different characters (augmenting paths, cards /// in catalog order); `None` when there is none or a snap class is used more often than it has snaps. - fn assign(&self, state: &[usize]) -> Option> { + pub(crate) fn assign(&self, state: &[usize]) -> Option> { let mut used = vec![0usize; self.snaps.len()]; for &o in state { if let Some(s) = self.opts[o].snap { @@ -596,7 +600,7 @@ impl<'a, 'm> Search<'a, 'm> { /// The default figure's gain of a state on the screened seeds, from the windows: `(score gain) / power + sum_k /// (weight gain of the plain skill at k)`, averaged. - fn value(&mut self, state: &[usize]) -> Result { + pub fn value(&mut self, state: &[usize]) -> Result { if let Some(&v) = self.cache.get(state) { return Ok(v); } @@ -673,7 +677,7 @@ impl<'a, 'm> Search<'a, 'm> { } /// Greedy fill, then coordinate descent: the chosen state and its screened value. - fn run(&mut self) -> Result<(Vec, f64), Error> { + pub fn run(&mut self) -> Result<(Vec, f64), Error> { let mut state: Vec = Vec::new(); let mut value = 0f64; for _ in 0..5 { @@ -745,6 +749,61 @@ impl<'a, 'm> Search<'a, 'm> { } } +/// A candidate performer of a search, for diagnostics. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchOption { + /// The member class's Gekisou skill; `None`: fillers (a skill of a mission the chart does not play). + pub skill: Option, + pub support: Option, + pub band_match: Option, + pub member_card_ids: Vec, + pub snap_ids: Vec, +} + +impl Search<'_, '_> { + /// The candidates; a state is a list of candidate indices, one per performer (at most five). + pub fn options(&self) -> Vec { + self.opts + .iter() + .map(|o| { + let first = &self.catalog.members[o.cards[0]]; + let support = o.snap.map(|s| self.snaps[s].support); + SearchOption { + skill: self.classes[o.class], + support, + band_match: support.and_then(|s| band_match(self.live.master, s, &first.performer)), + member_card_ids: o.cards.iter().map(|&c| self.catalog.members[c].id).collect(), + snap_ids: o.snap.map_or(Vec::new(), |s| { + self.snaps[s].snaps.iter().map(|&i| self.catalog.snaps[i].id).collect() + }), + } + }) + .collect() + } + + /// Whether a state has five different characters and every snap at most once. + pub fn feasible(&self, state: &[usize]) -> bool { + state.len() <= 5 && state.iter().all(|&o| o < self.opts.len()) && self.assign(state).is_some() + } + + /// Formations evaluated so far (cached states not counted again). + pub fn evaluations(&self) -> usize { + self.evaluations + } +} + +/// A chart's search, for [`super::search_probe`]. +pub(super) fn search<'a, 'm>( + live: &'a Live<'m>, + kinds: &'a [Kind], + infos: &'a [RangeInfo], + catalog: &'a Catalog, + seeds: &[SeedStats], +) -> Result, Error> { + Search::new(live, catalog, infos, plain_kind(kinds).map(|p| &kinds[p]), seeds, SCREEN_SEEDS) +} + /// The score at these ranks: `score - sum_i rankBonus_i + sum_i trunc(rangeScore_i * percent_i(ranks[i]) / 100)`. fn score_at_ranks(score: i32, ranges: &[GekisouRangeResult], infos: &[RangeInfo], ranks: &[i32]) -> Result { let mut s = score as i64; From 6f5eda517b9f589054a53ff32a8d122ca91bcb04 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 30 Sep 2026 03:06:39 +0800 Subject: [PATCH 03/11] fix: no let chains (MSRV 1.85) --- src/chartstats/gekisou_skills.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/chartstats/gekisou_skills.rs b/src/chartstats/gekisou_skills.rs index 50a69db..5395a6b 100644 --- a/src/chartstats/gekisou_skills.rs +++ b/src/chartstats/gekisou_skills.rs @@ -704,9 +704,7 @@ impl<'a, 'm> Search<'a, 'm> { s }) .collect(); - if let Some((s, v)) = self.best_of(cands)? - && v > value + 1e-12 - { + if let Some((s, v)) = self.best_of(cands)?.filter(|b| b.1 > value + 1e-12) { state = s; value = v; } @@ -733,9 +731,7 @@ impl<'a, 'm> Search<'a, 'm> { cands.push(s); } } - if let Some((s, v)) = self.best_of(cands)? - && v > value + 1e-12 - { + if let Some((s, v)) = self.best_of(cands)?.filter(|b| b.1 > value + 1e-12) { state = s; value = v; } From 0f6bc56c4d10acbffeaed7ea4d75c4bd2c374ca4 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 30 Sep 2026 03:14:27 +0800 Subject: [PATCH 04/11] fix: group support skill candidates by their band condition's targets; MSRV 1.88 for let chains --- .github/workflows/ci.yml | 2 +- Cargo.toml | 2 +- src/chartstats/gekisou_skills.rs | 26 ++++++++++++++++++-------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64b2b05..8631f9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,5 +25,5 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master with: - toolchain: "1.85.0" + toolchain: "1.88.0" - run: cargo check --all-targets diff --git a/Cargo.toml b/Cargo.toml index f62daba..d170b9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "ournotes-deck" version = "0.0.1" edition = "2024" -rust-version = "1.85" +rust-version = "1.88" description = "Deck power, skip score and live score for BanG Dream! Our Notes, with an exact Top-K deck search" license = "MIT OR Apache-2.0" repository = "https://github.com/empty-sekai/ournotes-deck" diff --git a/src/chartstats/gekisou_skills.rs b/src/chartstats/gekisou_skills.rs index 5395a6b..ae074c2 100644 --- a/src/chartstats/gekisou_skills.rs +++ b/src/chartstats/gekisou_skills.rs @@ -160,9 +160,16 @@ impl Catalog { } } -/// Whether a support skill's member target conditions (5000) hold for its member: `None` without such a condition. +/// Whether its member is a target of a support skill's member target conditions (5000, the band condition): `None` +/// without such a condition. fn band_match(master: &Master, support: SkillLevel, p: &Performer) -> Option { - let mut found: Option = None; + let hits = band_hits(master, support, p); + (!hits.is_empty()).then(|| hits.iter().any(|&h| h)) +} + +/// Per member target condition (5000) of a support skill's effects, in row order: whether its member is a target. +fn band_hits(master: &Master, support: SkillLevel, p: &Performer) -> Vec { + let mut found = Vec::new(); let rows = master.gekisou_support_skill_effects.iter().filter(|r| r.skill_id == support.id && r.level == support.level); for r in rows { @@ -181,8 +188,7 @@ fn band_match(master: &Master, support: SkillLevel, p: &Performer) -> Option Search<'a, 'm> { } for (si, sc) in snaps.iter().enumerate() { for (ci, (_, cards)) in classes.iter().enumerate() { - let mut groups: Vec<(Option, Vec)> = Vec::new(); + let mut groups: Vec<(Vec, Vec)> = Vec::new(); for &c in cards { - let b = band_match(live.master, sc.support, &catalog.members[c].performer); + let b = band_hits(live.master, sc.support, &catalog.members[c].performer); match groups.iter_mut().find(|g| g.0 == b) { Some(g) => g.1.push(c), None => groups.push((b, vec![c])), @@ -704,7 +710,9 @@ impl<'a, 'm> Search<'a, 'm> { s }) .collect(); - if let Some((s, v)) = self.best_of(cands)?.filter(|b| b.1 > value + 1e-12) { + if let Some((s, v)) = self.best_of(cands)? + && v > value + 1e-12 + { state = s; value = v; } @@ -731,7 +739,9 @@ impl<'a, 'm> Search<'a, 'm> { cands.push(s); } } - if let Some((s, v)) = self.best_of(cands)?.filter(|b| b.1 > value + 1e-12) { + if let Some((s, v)) = self.best_of(cands)? + && v > value + 1e-12 + { state = s; value = v; } From 7925771696bb85f50c35baf0e1749a7b809c6f9d Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 30 Sep 2026 03:26:50 +0800 Subject: [PATCH 05/11] style: let chains and is_multiple_of for MSRV 1.88 (clippy 1.98) Mechanical clippy --fix of collapsible_if and manual_is_multiple_of, which clippy 1.98 raises once the MSRV allows let chains; no logic change. Checked with clippy 1.98.1 -D warnings, cargo fmt --check and cargo +1.88.0 check --all-targets. --- src/bin/ournotes-deck.rs | 10 +++--- src/calc.rs | 12 ++++---- src/live/full/engine.rs | 57 +++++++++++++++++----------------- src/live/full/mod.rs | 60 +++++++++++++++++++----------------- src/live/full/raw_runtime.rs | 16 +++++----- src/search/live.rs | 8 ++--- src/search/mod.rs | 12 ++++---- src/search/power.rs | 27 ++++++++-------- src/search/snaps.rs | 58 ++++++++++++++++------------------ 9 files changed, 127 insertions(+), 133 deletions(-) diff --git a/src/bin/ournotes-deck.rs b/src/bin/ournotes-deck.rs index 0e48bcd..6421b5c 100644 --- a/src/bin/ournotes-deck.rs +++ b/src/bin/ournotes-deck.rs @@ -332,14 +332,14 @@ fn run(args: &[String]) -> Result { context_input.resolve(&data.master, scenario, score, fevers).map_err(|e| e.to_string()) }) .transpose()?; - if let Some(clock) = &context_input.result_clock { - if matches!( + if let Some(clock) = &context_input.result_clock + && matches!( (cmd, clock), ("skip", ournotes_deck::scenario::ResultClockInput::Played { .. }) | ("live", ournotes_deck::scenario::ResultClockInput::Skip { .. }) - ) { - return Err("resultClock execution does not match the command".into()); - } + ) + { + return Err("resultClock execution does not match the command".into()); } let objective = match cmd { "power" => Objective::Power { music_id: music, event }, diff --git a/src/calc.rs b/src/calc.rs index bef2d4f..07339d9 100644 --- a/src/calc.rs +++ b/src/calc.rs @@ -265,12 +265,12 @@ impl PowerCalculator { self.music_type_base.wrapping_add(rank_type_rate).wrapping_add(mu.type_bonus_rate), ); } - if let (Some(mtags), Some(stags)) = (m.best_music_tag_ids, mu.best_music_tag_ids) { - if mtags.iter().any(|a| stags.contains(a)) { - pct_mtag = CardPower::bp_single( - self.music_tag_base.wrapping_add(rank_tag_rate).wrapping_add(mu.tag_bonus_rate), - ); - } + if let (Some(mtags), Some(stags)) = (m.best_music_tag_ids, mu.best_music_tag_ids) + && mtags.iter().any(|a| stags.contains(a)) + { + pct_mtag = CardPower::bp_single( + self.music_tag_base.wrapping_add(rank_tag_rate).wrapping_add(mu.tag_bonus_rate), + ); } } let mtype = b.mul(pct_mtype).to_floor(); diff --git a/src/live/full/engine.rs b/src/live/full/engine.rs index 8b0b100..3377296 100644 --- a/src/live/full/engine.rs +++ b/src/live/full/engine.rs @@ -345,10 +345,10 @@ impl ConditionSkillUpdater { return self.finish_update(updated, done); } for ef in self.effects.iter_mut() { - if let Some(r) = ef.reset.as_mut() { - if r.check(ctx)?.0 { - self.execute_count.remove(&ef.effect_id); - } + if let Some(r) = ef.reset.as_mut() + && r.check(ctx)?.0 + { + self.execute_count.remove(&ef.effect_id); } } for &e in &self.sorted { @@ -385,17 +385,16 @@ impl ConditionSkillUpdater { continue; } let ef = &mut self.effects[e]; - if ef.execute_limit > 0 { - if let Some(&n) = self.execute_count.get(&ef.effect_id) { - if ef.execute_limit <= n { - continue; - } - } + if ef.execute_limit > 0 + && let Some(&n) = self.execute_count.get(&ef.effect_id) + && ef.execute_limit <= n + { + continue; } - if let Some(c) = ef.condition.as_mut() { - if !c.check(ctx)?.0 { - continue; - } + if let Some(c) = ef.condition.as_mut() + && !c.check(ctx)?.0 + { + continue; } let (limited, eid) = (ef.execute_limit > 0, ef.effect_id); let u = self.stacks[e].pop().expect("non-empty stack"); @@ -506,18 +505,18 @@ impl ConditionSkillUpdater { ctx: &mut CheckCtx, ) -> Result, Error> { let mut cur = s.current; - if let Some(c) = cur { - if self.updaters[c].state.state == END_FRAME { - self.update_one(c, inp, NO_TRIGGER, false, ctx)?; - s.queue.push_back(c); - s.current = None; - cur = None; - } + if let Some(c) = cur + && self.updaters[c].state.state == END_FRAME + { + self.update_one(c, inp, NO_TRIGGER, false, ctx)?; + s.queue.push_back(c); + s.current = None; + cur = None; } - if let Some(c) = cur { - if self.updaters[c].state.state == EXECUTE_FRAME { - self.updaters[c].state.state = EXECUTING; - } + if let Some(c) = cur + && self.updaters[c].state.state == EXECUTE_FRAME + { + self.updaters[c].state.state = EXECUTING; } if tr.is_trigger { if !s.enabled { @@ -526,10 +525,10 @@ impl ConditionSkillUpdater { } s.enabled = true; } - if let Some(c) = self.effects[e].condition.as_mut() { - if !c.check(ctx)?.0 { - return Ok(None); - } + if let Some(c) = self.effects[e].condition.as_mut() + && !c.check(ctx)?.0 + { + return Ok(None); } if cur.is_none() { let c = s.queue.pop_front().ok_or_else(|| Error::Game("sustained effect queue is empty".into()))?; diff --git a/src/live/full/mod.rs b/src/live/full/mod.rs index 45e82a6..87a9536 100644 --- a/src/live/full/mod.rs +++ b/src/live/full/mod.rs @@ -1476,15 +1476,15 @@ impl LiveModel { // effect state whose type TryGetApplier does not find. return Ok(()); } - if matches!(effect_type, 4000..=4004 | 13001) { - if let Some(runtime) = self.raw_runtime.as_mut() { - if let Some(time) = runtime.update_effect(key, st.state, &self.rows[ri])? { - let state = self.state_mut(item); - state.state = END_FRAME; - state.finish_ms = time; - } - return Ok(()); + if matches!(effect_type, 4000..=4004 | 13001) + && let Some(runtime) = self.raw_runtime.as_mut() + { + if let Some(time) = runtime.update_effect(key, st.state, &self.rows[ri])? { + let state = self.state_mut(item); + state.state = END_FRAME; + state.finish_ms = time; } + return Ok(()); } if gk_skill || gk_type { let done = self.apply_gekisou(ri, &mut st, owner, key); @@ -1581,10 +1581,10 @@ impl LiveModel { return Err(Error::Game("duplicate damage reduction effect state".into())); } self.life_reductions.insert(key, id); - } else if st.state == END_FRAME { - if let Some(id) = self.life_reductions.remove(&key) { - self.life.disable_damage_reduction(st.finish_ms, id)?; - } + } else if st.state == END_FRAME + && let Some(id) = self.life_reductions.remove(&key) + { + self.life.disable_damage_reduction(st.finish_ms, id)?; } } 3001 => { @@ -1758,10 +1758,11 @@ impl LiveModel { } let id = c.enable_combo_protect(st.execute_ms, row.effect_limit_count, (mask & 0xFF) as i32); ga.add(key, id)?; - } else if s == END_FRAME && !ga.exhausted.remove(&key) { - if let Some(id) = ga.ids.remove(&key) { - c.disable_combo_protect(st.finish_ms, id); - } + } else if s == END_FRAME + && !ga.exhausted.remove(&key) + && let Some(id) = ga.ids.remove(&key) + { + c.disable_combo_protect(st.finish_ms, id); } } 11005 => { @@ -1771,22 +1772,23 @@ impl LiveModel { let id = c.machine.enable_minimum(minimum_result_of(v), row.effect_limit_count); ga.add(key, id)?; } else if s == EXECUTING { - if let Some(&id) = ga.ids.get(&key) { - if !c.machine.is_minimum_active(id) { - let mut t = c.machine.take_last_consumed(id); - if t < 0 { - t = frame_t; - } - c.machine.disable_minimum(id); - ga.ids.remove(&key); - ga.exhausted.insert(key); - ga.limit_finished.insert(key, t); + if let Some(&id) = ga.ids.get(&key) + && !c.machine.is_minimum_active(id) + { + let mut t = c.machine.take_last_consumed(id); + if t < 0 { + t = frame_t; } - } - } else if s == END_FRAME && !ga.exhausted.remove(&key) { - if let Some(id) = ga.ids.remove(&key) { c.machine.disable_minimum(id); + ga.ids.remove(&key); + ga.exhausted.insert(key); + ga.limit_finished.insert(key, t); } + } else if s == END_FRAME + && !ga.exhausted.remove(&key) + && let Some(id) = ga.ids.remove(&key) + { + c.machine.disable_minimum(id); } } 2001 => { diff --git a/src/live/full/raw_runtime.rs b/src/live/full/raw_runtime.rs index 20db539..5a99302 100644 --- a/src/live/full/raw_runtime.rs +++ b/src/live/full/raw_runtime.rs @@ -64,11 +64,9 @@ impl RawJudgementRuntime { } } pub(super) fn convert_assist(&mut self, note: &mut RawJudgedNote, unchanged_by_skill: bool) -> Result<(), Error> { - if unchanged_by_skill { - if let Some(a) = self.assist.as_mut() { - note.result.judgement = - a.convert(note.note_id, ¬e.result, note.is_easy_flick, &self.windows.replicas).map_err(game)?; - } + if unchanged_by_skill && let Some(a) = self.assist.as_mut() { + note.result.judgement = + a.convert(note.note_id, ¬e.result, note.is_easy_flick, &self.windows.replicas).map_err(game)?; } Ok(()) } @@ -117,10 +115,10 @@ impl RawJudgementRuntime { let minimum = row.targets()?.first().copied().unwrap_or(3) as i32; let targets: Vec<_> = RELAX_TARGET_JUDGEMENTS.into_iter().filter(|&v| v >= minimum).collect(); self.percent.insert(owner, self.windows.percent(&targets, value as f32 / 10000.0)); - } else if phase == END_FRAME { - if let Some(delta) = self.percent.remove(&owner) { - self.windows.disable_percent(&delta); - } + } else if phase == END_FRAME + && let Some(delta) = self.percent.remove(&owner) + { + self.windows.disable_percent(&delta); } Ok(None) } diff --git a/src/search/live.rs b/src/search/live.rs index f348ce5..46e79c2 100644 --- a/src/search/live.rs +++ b/src/search/live.rs @@ -210,10 +210,10 @@ impl<'a> LiveCtx<'a> { } let mut best: Option<(i64, [usize; 5])> = None; for (bound, order) in cands { - if let Some(b) = best { - if bound < b.0 { - break; - } + if let Some(b) = best + && bound < b.0 + { + break; } let mut cmds = Vec::new(); for (e, &idx) in self.event_index.iter().enumerate() { diff --git a/src/search/mod.rs b/src/search/mod.rs index ac96301..eed5e28 100644 --- a/src/search/mod.rs +++ b/src/search/mod.rs @@ -295,12 +295,12 @@ fn validate_live_input(o: &Objective) -> Result<(), Error> { if *exclude_snap_skills { return Err(Error::Input("a live score with Gekisou on counts snap skills".into())); } - if let PlayInput::Stream { stream, .. } = play { - if stream.base_seed != 0 { - return Err(Error::Input( - "a live score with Gekisou on takes its seeds from the seed set, not the stream".into(), - )); - } + if let PlayInput::Stream { stream, .. } = play + && stream.base_seed != 0 + { + return Err(Error::Input( + "a live score with Gekisou on takes its seeds from the seed set, not the stream".into(), + )); } } match (exclude_snap_skills, play) { diff --git a/src/search/power.rs b/src/search/power.rs index 7f6afff..3075a53 100644 --- a/src/search/power.rs +++ b/src/search/power.rs @@ -256,12 +256,11 @@ impl<'a, 'm> PowerSearch<'a, 'm> { fn tick(&mut self) -> bool { self.stats.nodes += 1; - if self.stats.nodes % 256 == 0 { - if let Some(d) = self.deadline { - if Instant::now() >= d { - self.timed_out = true; - } - } + if self.stats.nodes.is_multiple_of(256) + && let Some(d) = self.deadline + && Instant::now() >= d + { + self.timed_out = true; } !self.timed_out && self.error.is_none() } @@ -292,10 +291,10 @@ impl<'a, 'm> PowerSearch<'a, 'm> { break; } // both the power bound and the gain bound fall as `q` grows - if let Some(g) = &l.gains { - if !self.open_gain(p, g, q, r, sel_pos, sel_sum) { - break; - } + if let Some(g) = &l.gains + && !self.open_gain(p, g, q, r, sel_pos, sel_sum) + { + break; } let rest = l.prefix[q + r] - l.prefix[q + 1]; for &(u, m) in &l.chars[q].1 { @@ -308,10 +307,10 @@ impl<'a, 'm> PowerSearch<'a, 'm> { np[k] = np[k].max(gm[k]); } let ns = sel_sum + gm.iter().copied().fold(0f64, f64::max); - if let Some(g) = &l.gains { - if !self.open_gain(cur + u + rest, g, q + 1, r - 1, np, ns) { - continue; - } + if let Some(g) = &l.gains + && !self.open_gain(cur + u + rest, g, q + 1, r - 1, np, ns) + { + continue; } picked.push(m); self.dfs(l, q + 1, r - 1, cur + u, picked, np, ns); diff --git a/src/search/snaps.rs b/src/search/snaps.rs index 3f94990..c6880e3 100644 --- a/src/search/snaps.rs +++ b/src/search/snaps.rs @@ -1705,12 +1705,11 @@ impl<'a> SnapLive<'a> { let a0 = coef.pc[ne]; let mut jp_of = [f64::INFINITY; 7]; for (x, slot) in jp_of.iter_mut().enumerate().skip(1) { - if let Ok(st) = convert_score_type(x as i64) { - if let Some(&p) = settings.judgement_score_factor_percent.get(&st) { - if p >= 0 { - *slot = p as f64 / 100.0; - } - } + if let Ok(st) = convert_score_type(x as i64) + && let Some(&p) = settings.judgement_score_factor_percent.get(&st) + && p >= 0 + { + *slot = p as f64 / 100.0; } } let mut fine = Fine { @@ -2870,12 +2869,11 @@ impl Leaf<'_, '_, '_> { cs: &mut [usize; 5], ) -> Result<(), Error> { self.nodes += 1; - if self.nodes % 1024 == 0 { - if let Some(d) = self.deadline { - if Instant::now() >= d { - self.timed_out = true; - } - } + if self.nodes.is_multiple_of(1024) + && let Some(d) = self.deadline + && Instant::now() >= d + { + self.timed_out = true; } if self.timed_out { return Ok(()); @@ -3120,12 +3118,12 @@ impl Leaf<'_, '_, '_> { rest.push(c); continue; } - if let Some(d) = self.deadline { - if Instant::now() >= d { - self.timed_out = true; - rest.push(c); - continue; - } + if let Some(d) = self.deadline + && Instant::now() >= d + { + self.timed_out = true; + rest.push(c); + continue; } let Some(score) = self.evaluate(&c)? else { done += 1; @@ -3174,11 +3172,11 @@ impl Leaf<'_, '_, '_> { self.count.seeds_saved += (n - j) as u64; break; } - if let Some(d) = self.deadline { - if Instant::now() >= d { - self.timed_out = true; - break; - } + if let Some(d) = self.deadline + && Instant::now() >= d + { + self.timed_out = true; + break; } if runner.is_none() { if done.is_empty() { @@ -3625,10 +3623,10 @@ struct GkRowWin { /// can start, a conversion converts the notes judged in the next frames up to the frame that processes its end. fn gk_row(env: &Env, r: &Row) -> Rc { let key = (r.trigger, r.trigger_type, r.gate, r.act.to_bits(), r.release); - if !g6_off(4) { - if let Some(w) = env.gk_cache.borrow().get(&key) { - return w.clone(); - } + if !g6_off(4) + && let Some(w) = env.gk_cache.borrow().get(&key) + { + return w.clone(); } let w = Rc::new(gk_row_timing(env, r)); env.gk_cache.borrow_mut().insert(key, w.clone()); @@ -4185,10 +4183,8 @@ impl GkFactors { if by_playing { lo = lo.min(if f >= 2 { frames[f - 2] } else { i32::MIN }); } - if by_combo { - if let Some(t) = current[f].and_then(|r| lc[r]) { - lo = lo.min(t); - } + if by_combo && let Some(t) = current[f].and_then(|r| lc[r]) { + lo = lo.min(t); } exec_lo[f] = lo; for &i in &by_frame[f] { From 8a64b7a89244cec2e4486ac8debf7d1d2a3751d1 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 30 Sep 2026 03:28:42 +0800 Subject: [PATCH 06/11] feat: chart-stats aptitude for Gekisou skills (shapes measured alone on the whole-live simulation) --- src/bin/ournotes-deck.rs | 24 +- src/chartstats.rs | 121 ++-- src/chartstats/aptitude.rs | 879 +++++++++++++++++++++++++ src/chartstats/gekisou_skills.rs | 1029 ------------------------------ src/live/full/mod.rs | 4 +- src/live/full/range_frames.rs | 65 ++ src/live/full/scorecalc.rs | 18 - src/live/full/window.rs | 224 ------- src/master.rs | 6 +- 9 files changed, 1007 insertions(+), 1363 deletions(-) create mode 100644 src/chartstats/aptitude.rs delete mode 100644 src/chartstats/gekisou_skills.rs create mode 100644 src/live/full/range_frames.rs delete mode 100644 src/live/full/window.rs diff --git a/src/bin/ournotes-deck.rs b/src/bin/ournotes-deck.rs index 0e48bcd..cf66acd 100644 --- a/src/bin/ournotes-deck.rs +++ b/src/bin/ournotes-deck.rs @@ -18,8 +18,8 @@ const USAGE: &str = "usage: ournotes-deck skip --data FILE --roster FILE --score ID [common options] ournotes-deck live --data FILE --roster FILE --score ID [--exclude-snap-skills] [--play FILE] [--event] [--gekisou (--seeds N | --seed-list S[,S...])] [common options] - ournotes-deck chart-stats --data FILE [--seeds N] [--formation-seeds N | --no-gekisou-skills] - [--charts ID[,ID...]] [--jobs N] [-o FILE] + ournotes-deck chart-stats --data FILE [--seeds N] [--no-gekisou-aptitude] [--aptitude-max-seeds N] + [--aptitude-cross-seeds N] [--charts ID[,ID...]] [--jobs N] [-o FILE] --data is a deck data file (nnnotes.deck-data/1). live scores the whole-live simulation with snap skills, where --play is a judgement stream; with --exclude-snap-skills it scores live skills only, where --play is a per-note play. --play defaults to the theoretical best play. --gekisou plays the live with Gekisou on and ranks by the sum @@ -31,11 +31,12 @@ conditional items also require --resource-type ID --resource-id ID and context.e scenario options: --scenario free|mission|battle|arena|challenge --scenario-music ID --context FILE --scenario-music is the special row ID for arena/challenge; --score always denotes the base chart. --context uses explicit powerSnapshot.eventIds and separate resultClock normalized DateTime ticks. -chart-stats measures every chart on the whole-live simulation (ournotes-deck.chart-stats/3): the no-skill score and +chart-stats measures every chart on the whole-live simulation (ournotes-deck.chart-stats/2): the no-skill score and the weight of every score-up kind at every position, with Gekisou on per seed (--seeds N for charts with a luck range, default 8; rank 1, range weights for the other ranks, the Perfect play's scores) and with Gekisou off (offSeeds); and -each chart's best Gekisou skill formation measured on --formation-seeds N seeds of a luck chart (default 32, the -first --seeds of them the chart's seeds). --charts keeps only these score ids; --jobs N measures N charts at once. +the chart's aptitude for Gekisou skills: every Gekisou skill shape of its missions alone (at most +--aptitude-max-seeds seeds, default 1024; the cross term on --aptitude-cross-seeds, default 64). +--charts keeps only these score ids; --jobs N measures N charts at once. common options: -k N (default 10), --leader ID, --include ID[,ID...], --exclude ID[,ID...], --exclude-snaps ID[,ID...], --no-snaps, --time-limit-ms N"; @@ -61,11 +62,16 @@ fn chart_stats(args: &[String]) -> Result, String> { match a { "--data" => data = Some(val()?), "--seeds" => seeds = Some(val()?.trim().parse::().map_err(|_| "bad --seeds".to_string())?), - "--formation-seeds" => { - let n = val()?.trim().parse::().map_err(|_| "bad --formation-seeds".to_string())?; - options.formation_seeds = Some(n); + "--no-gekisou-aptitude" => options.aptitude = None, + "--aptitude-max-seeds" | "--aptitude-cross-seeds" => { + let n = val()?.trim().parse::().map_err(|_| format!("bad {a}"))?; + let x = options.aptitude.get_or_insert_with(Default::default); + if a == "--aptitude-max-seeds" { + x.max_seeds = n; + } else { + x.cross_seeds = n; + } } - "--no-gekisou-skills" => options.formation_seeds = None, "--charts" => only = Some(ids(&val()?)?), "--jobs" => jobs = val()?.trim().parse::().map_err(|_| "bad --jobs".to_string())?.max(1), "-o" | "--out" => out = Some(val()?), diff --git a/src/chartstats.rs b/src/chartstats.rs index 6e65529..268876b 100644 --- a/src/chartstats.rs +++ b/src/chartstats.rs @@ -47,11 +47,11 @@ use serde::Serialize; -mod gekisou_skills; +mod aptitude; -pub use gekisou_skills::{ - Catalog, CatalogMember, CatalogSnap, FormationSlot, GekisouHeader, GekisouRangeResult, GekisouSeed, GekisouStats, - SCREEN_SEEDS, Search, SearchOption, SkillLevel, plain_kind, +pub use aptitude::{ + AptitudeHeader, AptitudeOptions, ChartAptitude, Condition, Cumulative, Effect, RangeDelta, RangeFactors, SeedRule, + Shape, ShapeSkill, Variant, VariantCheck, aptitude_header, shapes, }; use crate::data::{DataChart, DeckData}; @@ -67,15 +67,13 @@ use crate::num::ceil_to_i32; use crate::scenario::Scenario; /// Output format name. -pub const FORMAT: &str = "ournotes-deck.chart-stats/3"; +pub const FORMAT: &str = "ournotes-deck.chart-stats/2"; /// Deck power of the measurements (a power range of real decks). pub const POWER: i32 = 300_000; /// Deck power of the check deck. pub const CHECK_POWER: i32 = 1_000_003; /// Default number of seeds when a chart has a luck range. pub const GEKISOU_SEEDS: usize = 8; -/// Default number of seeds of a luck chart's best formation (`charts[].gekisou.seeds`). -pub const FORMATION_SEEDS: usize = 32; /// The effect value of factor 1 (`value / 10000`). pub const UNIT_VALUE: i64 = 10000; /// The most fevers a live can play: the game keeps three Gekisou ranges (`LiveMusicScore.GetGekisouRanges` @@ -441,10 +439,10 @@ pub struct ChartStats { /// Why the game cannot play the chart with Gekisou (Gekisou off plays it). #[serde(skip_serializing_if = "Option::is_none")] pub unplayable: Option, - /// The best Gekisou skill formation for the default scenario and its measurements ([`GekisouStats`]); `None` when - /// the chart is unplayable with Gekisou, has no Gekisou range, the master has no Gekisou skill or the statistics - /// leave the Gekisou skills out. - pub gekisou: Option, + /// The chart's aptitude for Gekisou skills: what each Gekisou (support) skill shape adds alone ([`ChartAptitude`]); + /// `None` when the chart is unplayable with Gekisou, has no Gekisou range, the master has no Gekisou skill or the + /// statistics leave the aptitude out. + pub gekisou_aptitude: Option, } /// What the chart statistics measure. @@ -452,14 +450,13 @@ pub struct ChartStats { pub struct Options { /// The size of the seed set of a chart with a luck range. pub seeds: usize, - /// The size of the seed set of a luck chart's best formation (at least `seeds`; its first seeds are the chart's); - /// `None`: no Gekisou skills. - pub formation_seeds: Option, + /// The aptitude for Gekisou skills; `None`: left out. + pub aptitude: Option, } impl Default for Options { fn default() -> Options { - Options { seeds: GEKISOU_SEEDS, formation_seeds: Some(FORMATION_SEEDS) } + Options { seeds: GEKISOU_SEEDS, aptitude: Some(AptitudeOptions::default()) } } } @@ -595,6 +592,22 @@ impl Live<'_> { seed: i32, ranks: Option<&[(i32, i64)]>, ) -> Result<(i32, Vec), Error> { + let (score, ranges, _) = self.run_counted(play, master, formation, skills, power, seed, ranks)?; + Ok((score, ranges)) + } + + /// [`Live::run_deck`], also the number of judgements converted by skills. + #[allow(clippy::too_many_arguments)] + fn run_counted( + &self, + play: &LivePlay, + master: &Master, + formation: &[Performer], + skills: &[Option], + power: i32, + seed: i32, + ranks: Option<&[(i32, i64)]>, + ) -> Result<(i32, Vec, u64), Error> { let deck: Vec = (0..skills.len().max(formation.len())) .map(|k| Performer { live_skill: skills.get(k).copied().flatten().map(|id| (id, 1)), @@ -609,7 +622,8 @@ impl Live<'_> { return Err(Error::Input("ranks without Gekisou".into())); } let mut lm = full::LiveModel::new(master, &deck, self.notes, self.events, params)?; - return Ok((lm.run(&play)?, Vec::new())); + let score = lm.run(&play)?; + return Ok((score, Vec::new(), lm.converted_judgements())); }; let mut lm = match ranks { None => full::LiveModel::new_gekisou(master, &deck, self.notes, self.events, params, &g.setup)?, @@ -623,7 +637,7 @@ impl Live<'_> { } }; let score = lm.run_timed(&play, &g.dt)?; - Ok((score, lm.gekisou_ranges())) + Ok((score, lm.gekisou_ranges(), lm.converted_judgements())) } /// A master with these skills added, `(live skill id, kind, value)`, one effect row each. @@ -813,7 +827,7 @@ impl Live<'_> { } /// The statistics of one chart for these kinds; `seeds` is the size of the seed set when a range is a luck range; the -/// best Gekisou skill formation on [`FORMATION_SEEDS`] seeds. +/// aptitude for Gekisou skills with the default [`AptitudeOptions`]. pub fn chart_stats(master: &Master, chart: &DataChart, kinds: &[Kind], seeds: usize) -> Result { chart_stats_with(master, chart, kinds, &Options { seeds, ..Options::default() }) } @@ -824,43 +838,6 @@ pub fn chart_stats_with( chart: &DataChart, kinds: &[Kind], options: &Options, -) -> Result { - chart_stats_inner(master, chart, kinds, options, None) -} - -/// A search probe: `f` gets the chart's formation search (screened on the first [`SCREEN_SEEDS`] of `seeds` seeds) -/// instead of the chart's Gekisou skill statistics; `None` when the chart has none. Diagnostics, not a stable -/// interface. -#[doc(hidden)] -pub fn search_probe( - master: &Master, - chart: &DataChart, - kinds: &[Kind], - seeds: usize, - f: impl FnOnce(&mut Search<'_, '_>) -> Result, -) -> Result, Error> { - let mut f = Some(f); - let mut out = None; - let mut hook = |s: &mut Search<'_, '_>| -> Result<(), Error> { - if let Some(f) = f.take() { - out = Some(f(s)?); - } - Ok(()) - }; - let options = Options { seeds, formation_seeds: Some(seeds) }; - chart_stats_inner(master, chart, kinds, &options, Some(&mut hook))?; - Ok(out) -} - -/// A hook that gets a chart's formation search. -type SearchHook<'h> = &'h mut dyn FnMut(&mut Search<'_, '_>) -> Result<(), Error>; - -fn chart_stats_inner( - master: &Master, - chart: &DataChart, - kinds: &[Kind], - options: &Options, - probe: Option>, ) -> Result { let seeds = options.seeds; let settings = LiveScoreSettings::from_master(master)?; @@ -923,7 +900,7 @@ fn chart_stats_inner( seeds: Vec::new(), off_seeds: Vec::new(), unplayable: None, - gekisou: None, + gekisou_aptitude: None, }; let notes: Vec = c .notes @@ -1001,30 +978,18 @@ fn chart_stats_inner( out.seeds.push(s); } - // the best Gekisou skill formation - if let Some(n) = options.formation_seeds { - let catalog = Catalog::new(master); - if catalog.has_skills() && !out.ranges.is_empty() { - let gk_seeds = if luck { published_seeds(n.max(seeds).max(1)) } else { vec![0] }; - let inputs = gekisou_skills::GkInputs { - seeds: &out.seeds, - gk_seeds: &gk_seeds, - linear, - score_id: chart.score_id, - judged, - }; - match probe { - Some(hook) => hook(&mut gekisou_skills::search(&live, kinds, &out.ranges, &catalog, &out.seeds)?)?, - None => { - out.gekisou = Some(gekisou_skills::gekisou_stats(&live, kinds, &out.ranges, &catalog, &inputs)?) - } - } + // the aptitude for Gekisou skills + if let Some(a) = &options.aptitude { + let shapes = shapes(master); + if !shapes.is_empty() && !out.ranges.is_empty() { + let inputs = aptitude::Inputs { seeds: &out.seeds, linear, score_id: chart.score_id, judged, options: a }; + out.gekisou_aptitude = Some(aptitude::chart_aptitude(&live, kinds, &out.ranges, &shapes, &inputs)?); } } Ok(out) } -/// The statistics of every chart of a deck data file (score id order) as the `ournotes-deck.chart-stats/3` document; +/// The statistics of every chart of a deck data file (score id order) as the `ournotes-deck.chart-stats/2` document; /// `seeds` is the size of the seed set of charts with a luck range (default [`GEKISOU_SEEDS`]). pub fn document(data: &DeckData, seeds: Option) -> Result { document_with(data, &Options { seeds: seeds.unwrap_or(GEKISOU_SEEDS), ..Options::default() }) @@ -1032,7 +997,7 @@ pub fn document(data: &DeckData, seeds: Option) -> Result Result { - if options.seeds == 0 || options.formation_seeds == Some(0) { + if options.seeds == 0 || options.aptitude.as_ref().is_some_and(|a| a.max_seeds == 0) { return Err(Error::Input("an empty seed set".into())); } let kinds = kinds(&data.master); @@ -1071,9 +1036,9 @@ pub fn document_with(data: &DeckData, options: &Options) -> Result AptitudeOptions { + AptitudeOptions { max_seeds: MAX_SEEDS, cross_seeds: CROSS_SEEDS } + } +} + +/// A skill condition of a shape's effect. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Condition { + #[serde(rename = "type")] + pub condition_type: i64, + pub values: Vec, + pub positive: bool, + /// `None` for a member target condition (5000): the band condition, measured both ways. + pub target_ids: Option>, +} + +/// The cumulative condition of a shape's effect. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Cumulative { + #[serde(rename = "type")] + pub cumulative_type: i64, + pub values: Vec, + pub target_ids: Vec, + pub max_cumulative_count: i64, +} + +/// An effect row of a shape; condition groups as their condition sets, each a list of conditions. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Effect { + pub effect_type: i64, + pub trigger_type: i64, + pub activation_time_second: f32, + pub effect_value: i64, + pub max_effect_value: i64, + pub effect_limit_count: i64, + pub effect_execute_limit_count: i64, + pub skill_target_ids: Vec, + pub trigger: Vec>, + pub condition: Vec>, + pub release: Vec>, + pub reset: Vec>, + pub cumulative: Option, +} + +/// A skill and level of a shape. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ShapeSkill { + pub id: i64, + pub level: i64, + /// The targets of its member target conditions (5000), ascending; `None` without one. + pub member_target_ids: Option>, + /// The bands of those targets, ascending; `None` without a member target condition. + pub band_ids: Option>, +} + +/// A Gekisou skill shape. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Shape { + pub id: usize, + /// `"member"` (a member card's Gekisou skill) or `"support"` (a snap's Gekisou support skill). + pub source: &'static str, + pub mission: i64, + /// Whether an effect has a member target condition (5000). + pub band_condition: bool, + pub effects: Vec, + pub skills: Vec, +} + +/// The seed rule of the aptitude, for the document. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeedRule { + pub deterministic_test: usize, + pub batches: Vec, + pub relative: f64, + pub baseline: f64, + pub cross_seeds: usize, +} + +/// The document header of the aptitude (`gekisouAptitude`). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AptitudeHeader { + pub plain_kind: Option, + pub host: &'static str, + pub seed_rule: SeedRule, + pub shapes: Vec, +} + +/// The factors of a range that shape the increments, from the chart's no-skill play. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RangeFactors { + /// Judged notes in the range's score frames (after the Start frame to the End frame). + pub judged_notes: i32, + /// Of them, judged Just. + pub just_notes: i32, + /// Of them, judged Perfect in a Just-count range (a judgement type without a Just row); 0 elsewhere. + pub perfect_notes: i32, + /// Notes judged after the End frame to the Complete frame (the tail). + pub tail_notes: i32, + /// Notes judged before the Start frame (the combo entering the range). + pub combo_at_start: i32, + /// Lotteries drawn without skills (the sum of the lottery results) over the chart's seeds, `[mean, se]`. + pub lotteries: [f64; 2], +} + +/// Increments of a range, each `[mean, se]`. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RangeDelta { + pub range_score: [f64; 2], + pub rank_bonus: [f64; 2], + pub range_score_perfect: [f64; 2], + pub max_combo: [f64; 2], + pub just_count: [f64; 2], + pub luck_points: [f64; 2], +} + +/// The check of a variant: the shape with a random plain deck at random ranks at the check power. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VariantCheck { + pub seed: i32, + pub ranks: Vec, + pub deck: Vec>, + pub exact: i32, + pub predicted: f64, + pub bound: f64, +} + +/// A shape (with a band condition result) on a chart: its increments, each `[mean, se]`. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Variant { + pub shape: usize, + pub band_match: Option, + pub deterministic: bool, + pub seeds: usize, + pub se_target_met: bool, + pub cross_seeds: usize, + pub score: [f64; 2], + pub score_perfect: [f64; 2], + pub tail: [f64; 2], + pub tail_perfect: [f64; 2], + pub converted: [f64; 2], + pub ranges: Vec, + /// The change of the plain kind's weight per position; `None` without a plain kind. + pub weights: Option>, + /// The change of its range weights per position and range; `None` without range weights or a plain kind. + pub range_weights: Option>>, + pub check: VariantCheck, +} + +/// A chart's aptitude for Gekisou skills. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChartAptitude { + pub factors: Vec, + pub variants: Vec, +} + +/// The plain kind: effect type 2000 on the whole deck for 5 s without targets, conditions or limits (the page's +/// `plainKind`). +pub fn plain_kind(kinds: &[Kind]) -> Option { + kinds + .iter() + .find(|k| { + k.effect_type == 2000 + && k.skill_target_ids.is_empty() + && k.skill_condition_group == 0 + && k.skill_release_condition_group == 0 + && k.effect_limit_count == 0 + && k.effect_execute_limit_count == 0 + && k.duration_ms == 5000 + }) + .map(|k| k.id) +} + +/// The header of the aptitude. +pub fn aptitude_header(master: &Master, kinds: &[Kind], options: &AptitudeOptions) -> AptitudeHeader { + let mut batches: Vec = BATCHES.iter().copied().filter(|&b| b <= options.max_seeds).collect(); + if batches.last().is_none_or(|&b| b < options.max_seeds) { + batches.push(options.max_seeds); + } + AptitudeHeader { + plain_kind: plain_kind(kinds), + host: HOST, + seed_rule: SeedRule { + deterministic_test: DETERMINISTIC_TEST, + batches, + relative: RELATIVE, + baseline: BASELINE, + cross_seeds: options.cross_seeds, + }, + shapes: shapes(master), + } +} + +/// A condition group as its condition sets (by id), each a list of conditions. +fn group(master: &Master, g: i64) -> Vec> { + if g == 0 { + return Vec::new(); + } + let mut sets: Vec<_> = master.skill_condition_sets.iter().filter(|s| s.group == g).collect(); + sets.sort_by_key(|s| s.id); + sets.iter() + .map(|s| { + s.condition_ids + .iter() + .filter_map(|&c| master.skill_condition(c)) + .map(|c| Condition { + condition_type: c.condition_type, + values: c.condition_values.clone(), + positive: c.is_positive, + target_ids: (c.condition_type != CONDITION_MEMBER_TARGET).then(|| c.condition_target_ids.clone()), + }) + .collect() + }) + .collect() +} + +fn effect(master: &Master, r: &GekisouSkillEffectRow) -> Effect { + let cumulative = if r.skill_cumulative_condition_id != 0 { + master.cumulative_condition(r.skill_cumulative_condition_id).map(|c| Cumulative { + cumulative_type: c.condition_type, + values: c.condition_values.clone(), + target_ids: c.condition_target_ids.clone(), + max_cumulative_count: c.max_cumulative_count, + }) + } else { + None + }; + Effect { + effect_type: r.skill_effect_type, + trigger_type: r.skill_trigger_type, + activation_time_second: r.activation_time_second, + effect_value: r.effect_value, + max_effect_value: r.max_effect_value, + effect_limit_count: r.effect_limit_count, + effect_execute_limit_count: r.effect_execute_limit_count, + skill_target_ids: r.skill_target_ids.clone(), + trigger: group(master, r.skill_trigger_condition_group), + condition: group(master, r.skill_condition_group), + release: group(master, r.skill_release_condition_group), + reset: group(master, r.effect_execute_limit_reset_condition_group), + cumulative, + } +} + +/// The groups of an effect row. +fn groups(r: &GekisouSkillEffectRow) -> [i64; 4] { + [ + r.skill_trigger_condition_group, + r.skill_condition_group, + r.skill_release_condition_group, + r.effect_execute_limit_reset_condition_group, + ] +} + +/// The member target conditions (5000) of effect rows: `(positive, target ids)`. +fn member_conditions(master: &Master, rows: &[&GekisouSkillEffectRow]) -> Vec<(bool, Vec)> { + let mut out = Vec::new(); + for r in rows { + for g in groups(r) { + if g == 0 { + continue; + } + for s in master.skill_condition_sets.iter().filter(|s| s.group == g) { + for c in s.condition_ids.iter().filter_map(|&c| master.skill_condition(c)) { + if c.condition_type == CONDITION_MEMBER_TARGET { + out.push((c.is_positive, c.condition_target_ids.clone())); + } + } + } + } + } + out +} + +/// The effect rows of a skill at a level, by row id. +fn skill_rows<'m>(master: &'m Master, source: &str, id: i64, level: i64) -> Vec<&'m GekisouSkillEffectRow> { + let table = if source == "member" { &master.gekisou_skill_effects } else { &master.gekisou_support_skill_effects }; + let mut rows: Vec<&GekisouSkillEffectRow> = table.iter().filter(|r| r.skill_id == id && r.level == level).collect(); + rows.sort_by_key(|r| r.id); + rows +} + +/// The Gekisou skill shapes of a master: member cards' Gekisou skills at their highest level and snaps' first Gekisou +/// support skills at the level of their highest rank, by skill id, grouped by source, mission and effects. +pub fn shapes(master: &Master) -> Vec { + let mut pairs: Vec<(&'static str, i64, i64)> = Vec::new(); + for c in &master.member_cards { + let id = c.gekisou_skill_id; + let level = master.gekisou_skill_effects.iter().filter(|r| id != 0 && r.skill_id == id).map(|r| r.level).max(); + if let Some(level) = level + && !pairs.contains(&("member", id, level)) + { + pairs.push(("member", id, level)); + } + } + for s in &master.support_cards { + let id = s.gekisou_support_skill_id_01; + let level = master + .support_card_ranks + .iter() + .filter(|r| r.group == s.rank_group) + .max_by_key(|r| r.rank) + .map_or(0, |r| r.gekisou_support_skill_01_level); + if id != 0 && level > 0 && !pairs.contains(&("support", id, level)) { + pairs.push(("support", id, level)); + } + } + pairs.sort_by_key(|&(source, id, level)| (source != "member", id, level)); + let mut out: Vec = Vec::new(); + for (source, id, level) in pairs { + let row: Option<&SkillRow> = + if source == "member" { master.gekisou_skill(id) } else { master.gekisou_support_skill(id) }; + let Some(row) = row else { continue }; + let rows = skill_rows(master, source, id, level); + if rows.is_empty() { + continue; + } + let effects: Vec = rows.iter().map(|r| effect(master, r)).collect(); + let conditions = member_conditions(master, &rows); + let band_condition = !conditions.is_empty(); + let mut targets: Vec = conditions.iter().flat_map(|c| c.1.iter().copied()).collect(); + targets.sort_unstable(); + targets.dedup(); + let mut bands: Vec = + targets.iter().filter_map(|&t| master.skill_target(t)).map(|t| t.band_id).filter(|&b| b > 0).collect(); + bands.sort_unstable(); + bands.dedup(); + let skill = ShapeSkill { + id, + level, + member_target_ids: band_condition.then(|| targets.clone()), + band_ids: band_condition.then_some(bands), + }; + let mission = row.gekisou_mission_type; + match out.iter_mut().find(|s| s.source == source && s.mission == mission && s.effects == effects) { + Some(s) => s.skills.push(skill), + None => { + out.push(Shape { id: out.len(), source, mission, band_condition, effects, skills: vec![skill] }); + } + } + } + out +} + +/// A master with the synthetic host Gekisou skills (missions 1 to 4, no effects). +fn with_hosts(master: &Master) -> Result { + let mut m = master.clone(); + for mission in 1..=4 { + m.gekisou_skills.push(SkillRow { + id: HOST_SKILL_BASE - mission, + skill_categories: Vec::new(), + gekisou_mission_type: mission, + }); + } + m.reindex()?; + Ok(m) +} + +/// Whether a performer is a target of member target conditions. +fn is_target(master: &Master, conditions: &[(bool, Vec)], p: &Performer) -> bool { + conditions + .iter() + .any(|(_, targets)| targets.iter().filter_map(|&t| master.skill_target(t)).any(|t| p.matches_skill_target(t))) +} + +/// The performer of a variant. +fn performer(master: &Master, shape: &Shape, band: Option) -> Result { + let s = &shape.skills[0]; + let mut p = Performer { gekisou_mission_type: shape.mission, ..Default::default() }; + if shape.source == "member" { + p.gekisou_skill = Some((s.id, s.level)); + } else { + p.gekisou_skill = Some((HOST_SKILL_BASE - shape.mission, 1)); + p.gekisou_support_skills = vec![(s.id, s.level)]; + } + if let Some(want) = band { + let rows = skill_rows(master, shape.source, s.id, s.level); + let conditions = member_conditions(master, &rows); + if want { + let t = + conditions.iter().flat_map(|c| c.1.iter()).find_map(|&t| master.skill_target(t)).ok_or_else(|| { + Error::Master(format!("shape {}: a member target condition without targets", shape.id)) + })?; + p.band_id = t.band_id; + p.character_id = t.character_id; + p.card_type = t.card_type; + if t.tag_id > 0 { + p.tag_ids = vec![t.tag_id]; + } + p.live_skill_categories = t.live_skill_categories.clone(); + p.gekisou_skill_categories = t.gekisou_skill_categories.clone(); + } + if is_target(master, &conditions, &p) != want { + return Err(Error::Game(format!("shape {}: no performer with band condition {want}", shape.id))); + } + } + Ok(p) +} + +/// The numbers of one live. +#[derive(Clone, Debug)] +struct Played { + score: i32, + ranges: Vec, + converted: u64, +} + +/// A seed's no-skill numbers: the Just and the Perfect play, and the plain kind at each position. +#[derive(Clone, Debug)] +struct BaseRun { + just: Played, + perfect: Played, + /// `(score, range scores)` with the plain kind at position k. + cross: Option)>>, +} + +/// A seed's increments. +#[derive(Clone, Debug, PartialEq)] +struct Sample { + seed: i32, + base_score: f64, + values: Vec, + /// `(weights per position, range weights per position and range)` changes. + cross: Option<(Vec, Vec>)>, +} + +/// The inputs of a chart's aptitude besides the live. +pub(super) struct Inputs<'a> { + pub seeds: &'a [SeedStats], + pub linear: bool, + pub score_id: i64, + pub judged: i32, + pub options: &'a AptitudeOptions, +} + +fn range_score(r: &GekisouRange) -> i32 { + r.end_score.wrapping_sub(r.start_score) +} + +/// The measurement of a chart. +struct Measure<'a, 'm> { + live: &'a Live<'m>, + master: Master, + measure: Master, + plain: Option, + unit: f64, + ranges: usize, + base: std::collections::HashMap, +} + +impl Measure<'_, '_> { + fn play( + &self, + perfect: bool, + master: &Master, + p: Option<&Performer>, + skills: &[Option], + seed: i32, + ) -> Result { + let g = self.live.gekisou.as_ref().ok_or_else(|| Error::Input("aptitude without Gekisou".into()))?; + let play = if perfect { &g.perfect } else { &self.live.play }; + let formation: Vec = p.into_iter().cloned().collect(); + let (score, ranges, converted) = self.live.run_counted(play, master, &formation, skills, POWER, seed, None)?; + Ok(Played { score, ranges, converted }) + } + + /// The no-skill numbers of a seed, with the plain kind's runs when `cross`. + fn base(&mut self, seed: i32, cross: bool) -> Result<&BaseRun, Error> { + let none = vec![None; self.live.positions]; + if !self.base.contains_key(&seed) { + let just = self.play(false, &self.master, None, &none, seed)?; + let perfect = self.play(true, &self.master, None, &none, seed)?; + self.base.insert(seed, BaseRun { just, perfect, cross: None }); + } + if cross && self.plain.is_some() && self.base[&seed].cross.is_none() { + let c = self.cross_runs(None, seed)?; + self.base.get_mut(&seed).expect("base").cross = Some(c); + } + Ok(&self.base[&seed]) + } + + /// `(score, range scores)` with the plain kind at each position. + fn cross_runs(&self, p: Option<&Performer>, seed: i32) -> Result)>, Error> { + let Some(plain) = self.plain else { return Ok(Vec::new()) }; + let mut out = Vec::with_capacity(self.live.positions); + for k in 0..self.live.positions { + let mut skills = vec![None; self.live.positions]; + skills[k] = Some(KIND_SKILL_BASE - plain as i64); + let r = self.play(false, &self.measure, p, &skills, seed)?; + out.push((r.score, r.ranges.iter().map(range_score).collect())); + } + Ok(out) + } + + /// A seed's increments of a performer: score, score on the Perfect play, tail, Perfect tail, conversions, then + /// per range the range score, rank bonus, Perfect range score, max combo, Just count and luck points. + fn sample(&mut self, p: &Performer, seed: i32, cross: bool) -> Result { + let none = vec![None; self.live.positions]; + let just = self.play(false, &self.master, Some(p), &none, seed)?; + let perfect = self.play(true, &self.master, Some(p), &none, seed)?; + let c = if cross && self.plain.is_some() { Some(self.cross_runs(Some(p), seed)?) } else { None }; + let ranges = self.ranges; + let unit = self.unit; + let b = self.base(seed, cross)?.clone(); + if just.ranges.len() != ranges || perfect.ranges.len() != ranges { + return Err(Error::Game("the shape's plays have other ranges".into())); + } + let tail = |x: &Played, y: &Played| -> f64 { + let mut t = (x.score as i64 - y.score as i64) as f64; + for (a, b) in x.ranges.iter().zip(&y.ranges) { + t -= (range_score(a) as i64 - range_score(b) as i64) as f64; + t -= (a.rank_bonus.unwrap_or(0) as i64 - b.rank_bonus.unwrap_or(0) as i64) as f64; + } + t + }; + let mut values = vec![ + (just.score as i64 - b.just.score as i64) as f64, + (perfect.score as i64 - b.perfect.score as i64) as f64, + tail(&just, &b.just), + tail(&perfect, &b.perfect), + just.converted as f64 - b.just.converted as f64, + ]; + for j in 0..ranges { + let (x, y) = (&just.ranges[j], &b.just.ranges[j]); + let (xp, yp) = (&perfect.ranges[j], &b.perfect.ranges[j]); + values.push((range_score(x) as i64 - range_score(y) as i64) as f64); + values.push((x.rank_bonus.unwrap_or(0) as i64 - y.rank_bonus.unwrap_or(0) as i64) as f64); + values.push((range_score(xp) as i64 - range_score(yp) as i64) as f64); + values.push((x.max_combo - y.max_combo) as f64); + values.push((x.just_count - y.just_count) as f64); + values.push((x.luck_points - y.luck_points) as f64); + } + let cross = match (c, &b.cross) { + (Some(c), Some(bc)) => { + let mut w = Vec::with_capacity(c.len()); + let mut rw = Vec::with_capacity(c.len()); + for ((s, rs), (s0, rs0)) in c.iter().zip(bc) { + let with = (*s as f64 - just.score as f64) / (POWER as f64 * unit); + let without = (*s0 as f64 - b.just.score as f64) / (POWER as f64 * unit); + w.push(with - without); + let per_range = (0..ranges) + .map(|j| { + let with = (rs[j] as f64 - range_score(&just.ranges[j]) as f64) / (POWER as f64 * unit); + let without = + (rs0[j] as f64 - range_score(&b.just.ranges[j]) as f64) / (POWER as f64 * unit); + with - without + }) + .collect(); + rw.push(per_range); + } + Some((w, rw)) + } + _ => None, + }; + Ok(Sample { seed, base_score: b.just.score as f64, values, cross }) + } +} + +/// `[mean, standard error]` of values. +fn mean_se(x: impl Iterator + Clone) -> [f64; 2] { + let n = x.clone().count(); + if n == 0 { + return [0.0, 0.0]; + } + let mean = x.clone().sum::() / n as f64; + if n == 1 { + return [mean, 0.0]; + } + let var = x.map(|v| (v - mean) * (v - mean)).sum::() / (n - 1) as f64; + [mean, (var / n as f64).sqrt()] +} + +/// Rounded for the output: points to 1e-3. +fn points(x: [f64; 2]) -> [f64; 2] { + [(x[0] * 1000.0).round() / 1000.0, (x[1] * 1000.0).round() / 1000.0] +} + +/// The range factors of a chart's play. +fn factors(live: &Live<'_>, infos: &[RangeInfo], seeds: &[SeedStats]) -> Result, Error> { + let g = live.gekisou.as_ref().ok_or_else(|| Error::Input("aptitude without Gekisou".into()))?; + let mut lm = LiveModel::new_gekisou(live.master, &[], live.notes, &[], live.params, &g.setup)?; + let frames = lm.record_range_frames(&live.play, &g.dt)?; + if frames.len() != infos.len() { + return Err(Error::Game("the play has other ranges".into())); + } + let time: std::collections::HashMap = live.notes.iter().map(|n| (n.note_id, n.time_ms)).collect(); + let mut out = Vec::with_capacity(infos.len()); + for (j, (info, rf)) in infos.iter().zip(&frames).enumerate() { + let (fs, fe) = (get_frame(info.start_ms), get_frame(info.end_ms)); + let (mut judged, mut just, mut perfect, mut tail, mut before) = (0, 0, 0, 0, 0); + for (i, f) in live.play.frames.iter().enumerate() { + for n in &f.judged { + let t = *time.get(&n.note_id).ok_or_else(|| Error::Input(format!("unknown note {}", n.note_id)))?; + let tf = get_frame(t); + if fs < tf && tf <= fe { + judged += 1; + if n.judgement == 6 { + just += 1; + } else if n.judgement == 5 && info.mission == 3 { + perfect += 1; + } + } + if rf.end < i && i <= rf.complete { + tail += 1; + } + if i < rf.start { + before += 1; + } + } + } + let lotteries = mean_se(seeds.iter().map(|s| s.ranges[j].lot_results.iter().sum::() as f64)); + out.push(RangeFactors { + judged_notes: judged, + just_notes: just, + perfect_notes: perfect, + tail_notes: tail, + combo_at_start: before, + lotteries, + }); + } + Ok(out) +} + +/// The aptitude of a chart for Gekisou skills. +pub(super) fn chart_aptitude( + live: &Live<'_>, + kinds: &[Kind], + infos: &[RangeInfo], + shapes: &[Shape], + inp: &Inputs<'_>, +) -> Result { + let plain = plain_kind(kinds); + let mut m = Measure { + live, + master: with_hosts(live.master)?, + measure: with_hosts(live.measure)?, + plain, + unit: plain.map_or(1.0, |p| kind_factor(kinds[p].effect_type, UNIT_VALUE)), + ranges: infos.len(), + base: std::collections::HashMap::new(), + }; + let missions: Vec = infos.iter().map(|r| r.mission).collect(); + let first_seed = inp.seeds.first().map_or(0, |s| s.seed); + let test_seeds = published_seeds(DETERMINISTIC_TEST); + let mut batches: Vec = BATCHES.iter().copied().filter(|&b| b <= inp.options.max_seeds).collect(); + if batches.last().is_none_or(|&b| b < inp.options.max_seeds) { + batches.push(inp.options.max_seeds); + } + let mut variants = Vec::new(); + for shape in shapes.iter().filter(|s| s.mission == 4 || missions.contains(&s.mission)) { + let bands: Vec> = if shape.band_condition { vec![Some(true), Some(false)] } else { vec![None] }; + for band in bands { + let p = performer(&m.master, shape, band)?; + // deterministic when the increments agree on the test seeds + let mut test = Vec::with_capacity(test_seeds.len()); + for &s in &test_seeds { + test.push(m.sample(&p, s, false)?.values); + } + let deterministic = test.windows(2).all(|w| w[0] == w[1]); + let mut samples: Vec = Vec::new(); + let mut met = true; + if deterministic { + samples.push(m.sample(&p, first_seed, true)?); + } else { + met = false; + for &n in &batches { + let seeds = published_seeds(n); + for (i, &s) in seeds.iter().enumerate().skip(samples.len()) { + samples.push(m.sample(&p, s, i < inp.options.cross_seeds)?); + } + let [mean, se] = mean_se(samples.iter().map(|s| s.values[0])); + let base = samples.iter().map(|s| s.base_score).sum::() / samples.len() as f64; + if se <= (RELATIVE * mean.abs()).max(BASELINE * base) { + met = true; + break; + } + } + } + let crossed: Vec<&Sample> = samples.iter().filter(|s| s.cross.is_some()).collect(); + let at = |i: usize| points(mean_se(samples.iter().map(move |s| s.values[i]))); + let ranges = (0..infos.len()) + .map(|j| { + let o = 5 + 6 * j; + RangeDelta { + range_score: at(o), + rank_bonus: at(o + 1), + range_score_perfect: at(o + 2), + max_combo: at(o + 3), + just_count: at(o + 4), + luck_points: at(o + 5), + } + }) + .collect(); + let weights = plain.map(|_| { + (0..live.positions) + .map(|k| mean_se(crossed.iter().map(|s| s.cross.as_ref().expect("cross").0[k]))) + .collect() + }); + let range_weights = (plain.is_some() && inp.linear).then(|| { + (0..live.positions) + .map(|k| { + (0..infos.len()) + .map(|j| mean_se(crossed.iter().map(|s| s.cross.as_ref().expect("cross").1[k][j]))) + .collect() + }) + .collect() + }); + let salt = inp.score_id as u64 ^ ((shape.id as u64) << 32) ^ (band.map_or(0, |b| 1 + b as u64) << 48); + let check = check(&mut m, kinds, infos, inp, &p, &samples[0], salt)?; + variants.push(Variant { + shape: shape.id, + band_match: band, + deterministic, + seeds: samples.len(), + se_target_met: met, + cross_seeds: crossed.len(), + score: at(0), + score_perfect: at(1), + tail: at(2), + tail_perfect: at(3), + converted: at(4), + ranges, + weights, + range_weights, + check, + }); + } + } + Ok(ChartAptitude { factors: factors(live, infos, inp.seeds)?, variants }) +} + +/// The check of a variant on its first seed: the shape with a random plain deck at random ranks (rank 1 where the +/// ranks do not follow linearly) at the check power, against the linear prediction from that seed's numbers. +fn check( + m: &mut Measure<'_, '_>, + kinds: &[Kind], + infos: &[RangeInfo], + inp: &Inputs<'_>, + p: &Performer, + s: &Sample, + salt: u64, +) -> Result { + let live = m.live; + let b = m.base(s.seed, true)?.clone(); + let mut rng = Rng(APT_CHECK_SALT ^ salt); + let mut rank_rng = Rng(APT_RANK_SALT ^ salt); + let ranks: Vec = + infos.iter().map(|_| if inp.linear { 1 + rank_rng.below(super::RANKS) as i32 } else { 1 }).collect(); + let mut d = Vec::with_capacity(infos.len()); + for (info, &r) in infos.iter().zip(&ranks) { + d.push(((info.percent(r)? - info.percent(1)?) as f64 / 100.0, info.percent(r)? as f64 / 100.0)); + } + // the base at these ranks (exact) and the shape's increment by the linear rank formula + let mut base = b.just.score as i64; + for (r, info_r) in b.just.ranges.iter().zip(infos.iter().zip(&ranks)) { + let (info, &rank) = info_r; + base += range_score(r) as i64 * info.percent(rank)? / 100 - r.rank_bonus.unwrap_or(0) as i64; + } + let mut delta = s.values[2]; + for (j, &(_, pr)) in d.iter().enumerate() { + delta += s.values[5 + 6 * j] * (1.0 + pr); + } + let usable: Vec = m.plain.into_iter().collect(); + let (deck, rows) = check_deck(kinds, &usable, live.positions, &mut rng); + let master = Live::master_with(&m.master, &rows); + let unit = m.unit; + let weight = |_: usize, k: usize| -> f64 { + let (Some(bc), Some((dw, drw))) = (&b.cross, &s.cross) else { return f64::NAN }; + let w0 = (bc[k].0 as f64 - b.just.score as f64) / (POWER as f64 * unit); + let mut w = w0 + dw[k]; + for (j, &(dp, _)) in d.iter().enumerate() { + let rw0 = (bc[k].1[j] as f64 - range_score(&b.just.ranges[j]) as f64) / (POWER as f64 * unit); + w += dp * (rw0 + drw[k][j]); + } + w + }; + let floors = inp.judged as f64 + MAX_GEKISOU_FEVERS as f64 + 2.0 * infos.len() as f64; + let slack = 2.0 * infos.len() as f64; + let confirmations: Vec<(i32, i64)> = + ranks.iter().zip(infos).map(|(&r, info)| Ok((r, info.percent(r)?))).collect::>()?; + let external = inp.linear.then_some(confirmations.as_slice()); + let per_power = (base as f64 + delta) / POWER as f64; + let c: Checked = live + .check_with(kinds, &master, std::slice::from_ref(p), &deck, s.seed, external, per_power, weight, floors, slack)? + .within(|| format!("Gekisou aptitude, seed {} at ranks {ranks:?}", s.seed))?; + Ok(VariantCheck { seed: s.seed, ranks, deck, exact: c.exact, predicted: c.predicted, bound: c.bound }) +} diff --git a/src/chartstats/gekisou_skills.rs b/src/chartstats/gekisou_skills.rs deleted file mode 100644 index ae074c2..0000000 --- a/src/chartstats/gekisou_skills.rs +++ /dev/null @@ -1,1029 +0,0 @@ -//! Gekisou skills in the chart statistics: a chart's best formation for the page's default scenario, found on the -//! Gekisou windows of the simulation ([`crate::live::full::window_note_scores`]) and measured on the whole-live -//! simulation. -//! -//! A formation is five performers, each a member card with its Gekisou skill at the skill's highest level and -//! optionally a snap with its Gekisou support skill at the level of the snap's highest rank; the five members are -//! five different characters and a snap is used at most once. The default scenario is a Gekisou live (Battle Live) at -//! rank 1 in every range, the theoretical best play (every Just inside the Just-count ranges) and a deck of plain -//! score-up skills (the plain kind, [`plain_kind`], at factor 1 at every position); its figure per unit of deck power -//! is `score / power + sum_k weights[plain][k]`, and the search maximises the formation's gain of that figure averaged -//! over the seeds ([`GekisouStats::objective`]). -//! -//! The search evaluates a formation on the Gekisou windows only (the frames from each range's Start to its Finish, -//! where every Gekisou skill acts): the window's note scores are recomputed from the commands filed there, the range -//! scores and rank bonuses follow, and the plain skill's weights move by the change of the window notes' score per unit -//! of factor on the notes each position's skill covers. It fills the five performers greedily, then replaces one -//! performer's member or snap, or swaps two performers' snaps, while the figure improves. Member cards with the same -//! Gekisou skill are one candidate, snaps with the same support skill and level too; a support skill's band condition -//! (condition 5000 on its own member) splits a candidate by its result. Skills of missions the chart does not play never -//! act, so their members are fillers; support skills whose effects change no score on the best play (combo protection, -//! Great to Perfect, the Just window) are left out. The result is a local optimum of the screened seeds; the chosen -//! formation is then measured on the whole-live simulation on every seed ([`GekisouSeed`]). - -use std::collections::HashMap; - -use serde::Serialize; - -use super::{ - Check, KIND_SKILL_BASE, Kind, Live, MAX_GEKISOU_FEVERS, POWER, RANKS, RangeInfo, RankCheck, Rng, SeedStats, - UNIT_VALUE, check_deck, kind_factor, -}; -use crate::error::Error; -use crate::live::full::{GekisouRange, LiveModel, LiveNote, NoteEval, Performer, WindowNote, window_note_scores}; -use crate::live::score::{GekisouComboInfo, convert_score_type, get_frame}; -use crate::live::skill::{FactorCommand, OWNER_MEMBER}; -use crate::master::{GekisouSkillEffectRow, Master}; - -/// The number of seeds a luck chart's search screens formations on (the first seeds of its seed set). -pub const SCREEN_SEEDS: usize = 8; -/// Sweeps of the coordinate descent at most. -const MAX_SWEEPS: usize = 4; -/// Skill condition type of a member target: a support skill's condition on its own member. -const CONDITION_MEMBER_TARGET: i64 = 5000; -/// Support effect types that change no score on the theoretical best play (no Great, Bad or Miss): combo protection, -/// Great to Perfect, the Just window of a judged stream. -const ZERO_SUPPORT_TYPES: [i64; 3] = [12004, 12006, 4004]; -/// Start states of the generators of the formation's check decks and ranks, each xored with the score id. -const GK_CHECK_SALT: u64 = 0x676b_5f63_6865_636b; -const GK_RANK_SALT: u64 = 0x676b_5f72_616e_6b73; -/// The method, for the document. -pub const SEARCH: &str = "greedy fill of the five performers, then coordinate descent (one performer's member or \ - snap replaced, or two performers' snaps swapped) while the default figure improves; member cards with the same \ - Gekisou skill are one candidate, snaps with the same support skill and level too, split by the support skill's \ - band condition on its member; members whose skill's mission the chart does not play are fillers, support skills \ - that change no score on the best play (12004, 12006, 4004) are left out; formations are evaluated on the Gekisou \ - windows of the simulation (the frames from each range's Start to its Finish), a luck chart on its first \ - screenSeeds seeds; the chosen formation is measured on the whole-live simulation on every seed. A local optimum, \ - not proven global, chosen for the default scenario only (rank 1 in every range, every Just, no Great, the plain \ - kind at factor 1 at every position)"; - -/// A skill and its level. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] -pub struct SkillLevel { - pub id: i64, - pub level: i64, -} - -/// A member card of the catalog. -#[derive(Clone, Debug)] -pub struct CatalogMember { - pub id: i64, - pub character_id: i64, - pub band_id: i64, - /// The Gekisou skill at its highest level; `None` without one. - pub skill: Option, - /// The Gekisou skill's mission (0 without one). - pub mission: i64, - performer: Performer, -} - -/// A snap of the catalog. -#[derive(Clone, Debug)] -pub struct CatalogSnap { - pub id: i64, - /// The first Gekisou support skill at the level of the snap's highest rank; `None` without one. - pub support: Option, - pub mission: i64, - /// Whether every effect of the support skill at that level is of [`ZERO_SUPPORT_TYPES`]. - pub zero: bool, -} - -/// The member cards and snaps of a master, with the Gekisou (support) skills a formation takes. -#[derive(Clone, Debug, Default)] -pub struct Catalog { - pub members: Vec, - pub snaps: Vec, -} - -fn max_level(rows: &[GekisouSkillEffectRow], id: i64) -> Option { - if id == 0 { - return None; - } - rows.iter().filter(|r| r.skill_id == id).map(|r| r.level).max() -} - -impl Catalog { - pub fn new(master: &Master) -> Catalog { - let mut members = Vec::with_capacity(master.member_cards.len()); - for c in &master.member_cards { - let band_id = master.character(c.character_id).map_or(0, |ch| ch.band_id); - let skill = max_level(&master.gekisou_skill_effects, c.gekisou_skill_id) - .map(|level| SkillLevel { id: c.gekisou_skill_id, level }); - let row = master.gekisou_skill(c.gekisou_skill_id); - let mission = if skill.is_some() { row.map_or(0, |r| r.gekisou_mission_type) } else { 0 }; - let performer = Performer { - band_id, - character_id: c.character_id, - card_type: c.card_type, - tag_ids: c.best_music_tag_ids.clone(), - live_skill_categories: master - .live_skill(c.live_skill_id) - .map(|s| s.skill_categories.clone()) - .unwrap_or_default(), - gekisou_skill_categories: row.map(|s| s.skill_categories.clone()).unwrap_or_default(), - gekisou_mission_type: mission, - gekisou_skill: skill.map(|s| (s.id, s.level)), - ..Default::default() - }; - members.push(CatalogMember { id: c.id, character_id: c.character_id, band_id, skill, mission, performer }); - } - let mut snaps = Vec::with_capacity(master.support_cards.len()); - for s in &master.support_cards { - let id = s.gekisou_support_skill_id_01; - let level = master - .support_card_ranks - .iter() - .filter(|r| r.group == s.rank_group) - .max_by_key(|r| r.rank) - .map_or(0, |r| r.gekisou_support_skill_01_level); - let rows: Vec<&GekisouSkillEffectRow> = master - .gekisou_support_skill_effects - .iter() - .filter(|r| id != 0 && r.skill_id == id && r.level == level) - .collect(); - let support = (!rows.is_empty()).then_some(SkillLevel { id, level }); - let mission = if support.is_some() { - master.gekisou_support_skill(id).map_or(0, |r| r.gekisou_mission_type) - } else { - 0 - }; - let zero = rows.iter().all(|r| ZERO_SUPPORT_TYPES.contains(&r.skill_effect_type)); - snaps.push(CatalogSnap { id: s.id, support, mission, zero }); - } - Catalog { members, snaps } - } - - /// Whether some member card has a Gekisou skill. - pub fn has_skills(&self) -> bool { - self.members.iter().any(|m| m.skill.is_some()) - } -} - -/// Whether its member is a target of a support skill's member target conditions (5000, the band condition): `None` -/// without such a condition. -fn band_match(master: &Master, support: SkillLevel, p: &Performer) -> Option { - let hits = band_hits(master, support, p); - (!hits.is_empty()).then(|| hits.iter().any(|&h| h)) -} - -/// Per member target condition (5000) of a support skill's effects, in row order: whether its member is a target. -fn band_hits(master: &Master, support: SkillLevel, p: &Performer) -> Vec { - let mut found = Vec::new(); - let rows = - master.gekisou_support_skill_effects.iter().filter(|r| r.skill_id == support.id && r.level == support.level); - for r in rows { - for g in [r.skill_trigger_condition_group, r.skill_condition_group] { - if g == 0 { - continue; - } - for s in master.skill_condition_sets.iter().filter(|s| s.group == g) { - for &cid in &s.condition_ids { - let Some(c) = master.skill_condition(cid) else { continue }; - if c.condition_type != CONDITION_MEMBER_TARGET { - continue; - } - let hit = c - .condition_target_ids - .iter() - .filter_map(|&t| master.skill_target(t)) - .any(|t| p.matches_skill_target(t)); - found.push(hit); - } - } - } - } - found -} - -/// One performer of a formation. -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct FormationSlot { - pub member_card_id: i64, - pub character_id: i64, - pub band_id: i64, - pub gekisou_skill: SkillLevel, - pub snap_id: Option, - pub support_skill: Option, - /// Whether the support skill's band condition (5000) holds for the member; `None` without a support skill or - /// without such a condition. - pub band_match: Option, -} - -/// A range of a seed with the formation. -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GekisouRangeResult { - pub range_score: i32, - pub rank_bonus: i32, - pub range_score_perfect: i32, - pub max_combo: i32, - pub just_count: i32, - pub luck_points: i32, - pub lot_results: [i32; 4], -} - -/// A seed of the best formation, shaped as a no-skill seed ([`SeedStats`]): the whole-live simulation with the -/// formation; only the plain kind has weights. -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GekisouSeed { - pub seed: i32, - /// The exact score with the formation and no live skill, rank 1 bonuses included. - pub score: i32, - /// The same on the Perfect play. - pub score_perfect: i32, - pub ranges: Vec, - /// `weights[kind][position]` with the formation, the plain kind only (every other kind `None`). - pub weights: Vec>>, - /// `rangeWeights[kind][position][range]`, the plain kind only; `None` when the chart's no-skill seeds have none. - pub range_weights: Option>>>>, - /// A random deck of the plain kind's master values with the formation at [`super::CHECK_POWER`]. - pub check: Check, - /// The check deck at random ranks; `None` without range weights or without ranges. - pub rank_check: Option, -} - -/// The Gekisou skill statistics of a chart: its best formation for the default scenario and the formation's seeds. -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GekisouStats { - pub formation: Vec, - /// The formation's gain of the default figure on the whole-live simulation: the mean over the seeds of - /// `score / power + sum_k weights[plain][k]` with the formation minus the same without it. - pub objective: f64, - /// The same gain on the Gekisou windows of the screened seeds, as the search saw it. - pub screened: f64, - /// Formations the search evaluated. - pub evaluations: usize, - pub seeds: Vec, -} - -/// The document header of the Gekisou skills (`gekisouSkills`). -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GekisouHeader { - pub plain_kind: Option, - pub member_level: &'static str, - pub snap_level: &'static str, - pub search: &'static str, - pub screen_seeds: usize, - /// Seeds of a luck chart's formation (`charts[].gekisou.seeds`); the first ones are the chart's seeds. - pub seeds: usize, -} - -impl GekisouHeader { - pub fn new(kinds: &[Kind], seeds: usize) -> GekisouHeader { - GekisouHeader { - plain_kind: plain_kind(kinds), - member_level: "highest", - snap_level: "highestRank", - search: SEARCH, - screen_seeds: SCREEN_SEEDS, - seeds, - } - } -} - -/// The plain kind: effect type 2000 on the whole deck for 5 s without targets, conditions or limits (the page's -/// `plainKind`). -pub fn plain_kind(kinds: &[Kind]) -> Option { - kinds - .iter() - .find(|k| { - k.effect_type == 2000 - && k.skill_target_ids.is_empty() - && k.skill_condition_group == 0 - && k.skill_release_condition_group == 0 - && k.effect_limit_count == 0 - && k.effect_execute_limit_count == 0 - && k.duration_ms == 5000 - }) - .map(|k| k.id) -} - -struct Info<'a>(&'a LiveModel); - -impl GekisouComboInfo for Info<'_> { - fn gekisou_combo(&self, t: i32) -> Option { - self.0.gekisou_combo_at(t) - } -} - -/// A candidate performer: a member class (`class` into the classes), a snap class (`None`: no snap) and the band -/// condition's result, with the member cards (catalog indices) that give it. -#[derive(Clone, Debug)] -struct Opt { - class: usize, - snap: Option, - cards: Vec, -} - -/// A snap class: a support skill and level, the snaps (catalog indices) that have it. -#[derive(Clone, Debug)] -struct SnapClass { - support: SkillLevel, - snaps: Vec, -} - -/// A screened seed: the no-formation window, range scores and rank bonuses. -struct Base { - seed: i32, - window: Vec, - range_scores: Vec, - rank_bonuses: Vec, -} - -/// The formation search of a chart. Its public methods are diagnostics ([`super::search_probe`]), not a stable -/// interface. -pub struct Search<'a, 'm> { - live: &'a Live<'m>, - catalog: &'a Catalog, - infos: &'a [RangeInfo], - frames: Vec, - notes: Vec, - /// Bit i: the note is in range i's score frames. - in_range: Vec, - /// Per position: the window notes the plain skill covers there. - cover: Vec>, - snaps: Vec, - /// The member classes' Gekisou skills (`None`: the fillers). - classes: Vec>, - opts: Vec, - base: Vec, - cache: HashMap, f64>, - evaluations: usize, -} - -impl<'a, 'm> Search<'a, 'm> { - fn new( - live: &'a Live<'m>, - catalog: &'a Catalog, - infos: &'a [RangeInfo], - plain: Option<&Kind>, - seeds: &[SeedStats], - screen: usize, - ) -> Result, Error> { - let g = live.gekisou.as_ref().ok_or_else(|| Error::Input("Gekisou skills without Gekisou".into()))?; - let missions: Vec = infos.iter().map(|r| r.mission).collect(); - let mut lm = LiveModel::new_gekisou(live.master, &[], live.notes, &[], live.params, &g.setup)?; - let rf = lm.record_range_frames(&live.play, &g.dt)?; - let mut frames: Vec = rf.iter().flat_map(|r| r.start..=r.finish).collect(); - frames.sort_unstable(); - frames.dedup(); - let by_id: HashMap = live.notes.iter().map(|n| (n.note_id, n)).collect(); - let note = |id: i32| by_id.get(&id).copied().ok_or_else(|| Error::Input(format!("unknown note {id}"))); - let mut times: Vec = Vec::new(); - for f in &live.play.frames { - for j in &f.judged { - times.push(note(j.note_id)?.time_ms); - } - } - times.sort_unstable(); - let mut notes = Vec::new(); - for &fi in &frames { - for j in &live.play.frames[fi].judged { - let n = *note(j.note_id)?; - let combo = times.partition_point(|&t| t < n.time_ms) as i32; - notes.push(WindowNote { note: n, combo, score_type: 0 }); - } - } - let in_range = notes - .iter() - .map(|n| { - let f = get_frame(n.note.time_ms); - infos.iter().enumerate().fold(0u8, |m, (i, r)| { - if get_frame(r.start_ms) < f && f <= get_frame(r.end_ms) { m | (1 << i) } else { m } - }) - }) - .collect(); - - // candidates: members of the chart's missions by skill, the rest fillers; snaps of its missions by support - // skill and level - let playing = |mission: i64| mission != 0 && (mission == 4 || missions.contains(&mission)); - let mut classes: Vec<(Option, Vec)> = vec![(None, Vec::new())]; - for (i, m) in catalog.members.iter().enumerate() { - if m.skill.is_none() { - continue; - } - let key = m.skill.filter(|_| playing(m.mission)); - match classes.iter_mut().find(|c| c.0 == key) { - Some(c) => c.1.push(i), - None => classes.push((key, vec![i])), - } - } - let mut snaps: Vec = Vec::new(); - for (i, s) in catalog.snaps.iter().enumerate() { - let Some(support) = s.support else { continue }; - if s.zero || !playing(s.mission) { - continue; - } - match snaps.iter_mut().find(|c| c.support == support) { - Some(c) => c.snaps.push(i), - None => snaps.push(SnapClass { support, snaps: vec![i] }), - } - } - let mut opts = Vec::new(); - for (ci, (_, cards)) in classes.iter().enumerate() { - if !cards.is_empty() { - opts.push(Opt { class: ci, snap: None, cards: cards.clone() }); - } - } - for (si, sc) in snaps.iter().enumerate() { - for (ci, (_, cards)) in classes.iter().enumerate() { - let mut groups: Vec<(Vec, Vec)> = Vec::new(); - for &c in cards { - let b = band_hits(live.master, sc.support, &catalog.members[c].performer); - match groups.iter_mut().find(|g| g.0 == b) { - Some(g) => g.1.push(c), - None => groups.push((b, vec![c])), - } - } - for (_, cards) in groups { - opts.push(Opt { class: ci, snap: Some(si), cards }); - } - } - } - let mut out = Search { - live, - catalog, - infos, - frames, - notes, - in_range, - cover: Vec::new(), - snaps, - classes: classes.iter().map(|c| c.0).collect(), - opts, - base: Vec::new(), - cache: HashMap::new(), - evaluations: 0, - }; - if let Some(kind) = plain { - out.cover = out.plain_cover(kind)?; - } - for s in seeds.iter().take(screen.max(1)) { - let window = out.window(&[], s.seed)?; - out.base.push(Base { - seed: s.seed, - window, - range_scores: s.ranges.iter().map(|r| r.range_score as i64).collect(), - rank_bonuses: s.ranges.iter().map(|r| r.rank_bonus as i64).collect(), - }); - } - Ok(out) - } - - /// Per position, the window notes the plain skill covers there: the notes at or after a factor command it files - /// and before the command that takes it back. - fn plain_cover(&self, kind: &Kind) -> Result>, Error> { - let g = self.live.gekisou.as_ref().expect("Gekisou on"); - let id = KIND_SKILL_BASE - kind.id as i64; - let mut out = Vec::with_capacity(self.live.positions); - for k in 0..self.live.positions { - let deck: Vec = (0..self.live.positions) - .map(|i| Performer { live_skill: (i == k).then_some((id, 1)), ..Default::default() }) - .collect(); - let (notes, events, params) = (self.live.notes, self.live.events, self.live.params); - let mut lm = LiveModel::new_gekisou(self.live.measure, &deck, notes, events, params, &g.setup)?; - lm.run_timed(&self.live.play, &g.dt)?; - let owner = (k as i32) * 100 + OWNER_MEMBER; - let mut cmds: Vec = - lm.factor_commands().into_iter().filter(|c| c.owner_id == owner && c.note_mill != 0).collect(); - cmds.sort_by_key(|c| c.time_ms); - let mut spans: Vec<(i32, i32)> = Vec::new(); - let mut open: Option = None; - for c in cmds { - if c.note_mill > 0 { - open.get_or_insert(c.time_ms); - } else if let Some(s) = open.take() { - spans.push((s, c.time_ms)); - } - } - if let Some(s) = open { - spans.push((s, i32::MAX)); - } - out.push( - self.notes - .iter() - .map(|n| spans.iter().any(|&(s, e)| s <= n.note.time_ms && n.note.time_ms < e)) - .collect(), - ); - } - Ok(out) - } - - /// The window notes' scores with these performers on a seed. - fn window(&self, deck: &[Performer], seed: i32) -> Result, Error> { - let g = self.live.gekisou.as_ref().expect("Gekisou on"); - let mut lm = LiveModel::new_gekisou(self.live.master, deck, self.live.notes, &[], self.live.params, &g.setup)?; - let judged = lm.run_frames(&self.live.play, seed, &g.dt, &self.frames)?; - if judged.len() != self.notes.len() { - return Err(Error::Game("the window run judged other notes".into())); - } - let mut notes = self.notes.clone(); - for (n, &(id, j)) in notes.iter_mut().zip(&judged) { - if n.note.note_id != id { - return Err(Error::Game("the window run judged the notes in another order".into())); - } - n.score_type = convert_score_type(j as i64)?; - } - let calc = lm.initial_calculator(self.live.params.total_power); - let cmds = lm.factor_commands(); - window_note_scores(&calc, lm.score_max_frame(), ¬es, &cmds, &Info(&lm)) - } - - /// The performers of a formation state: a representative member card of each candidate with its snap's support - /// skill, at least five performers. - fn deck(&self, state: &[usize]) -> Vec { - let mut out: Vec = state - .iter() - .map(|&o| { - let opt = &self.opts[o]; - let mut p = self.catalog.members[opt.cards[0]].performer.clone(); - if let Some(s) = opt.snap { - let s = self.snaps[s].support; - p.gekisou_support_skills = vec![(s.id, s.level)]; - } - p - }) - .collect(); - while out.len() < self.live.positions.max(5) { - out.push(Performer::default()); - } - out - } - - /// A member card (catalog index) per performer of a state, five different characters (augmenting paths, cards - /// in catalog order); `None` when there is none or a snap class is used more often than it has snaps. - pub(crate) fn assign(&self, state: &[usize]) -> Option> { - let mut used = vec![0usize; self.snaps.len()]; - for &o in state { - if let Some(s) = self.opts[o].snap { - used[s] += 1; - if used[s] > self.snaps[s].snaps.len() { - return None; - } - } - } - fn augment( - s: &Search<'_, '_>, - state: &[usize], - j: usize, - seen: &mut Vec, - owner: &mut HashMap, - card: &mut [usize], - ) -> bool { - for &c in &s.opts[state[j]].cards { - let ch = s.catalog.members[c].character_id; - if seen.contains(&ch) { - continue; - } - seen.push(ch); - let free = match owner.get(&ch).copied() { - None => true, - Some(other) => augment(s, state, other, seen, owner, card), - }; - if free { - owner.insert(ch, j); - card[j] = c; - return true; - } - } - false - } - let mut owner: HashMap = HashMap::new(); - let mut card = vec![usize::MAX; state.len()]; - for j in 0..state.len() { - let mut seen = Vec::new(); - if !augment(self, state, j, &mut seen, &mut owner, &mut card) { - return None; - } - } - Some(card) - } - - /// The default figure's gain of a state on the screened seeds, from the windows: `(score gain) / power + sum_k - /// (weight gain of the plain skill at k)`, averaged. - pub fn value(&mut self, state: &[usize]) -> Result { - if let Some(&v) = self.cache.get(state) { - return Ok(v); - } - let deck = self.deck(state); - let mut total = 0f64; - for b in &self.base { - let e = match self.window(&deck, b.seed) { - Ok(e) => e, - // a skill the simulation cannot play: never chosen - Err(Error::Unsupported(_)) => { - self.cache.insert(state.to_vec(), f64::NEG_INFINITY); - return Ok(f64::NEG_INFINITY); - } - Err(e) => return Err(e), - }; - let ranges = self.infos.len(); - let mut dn = 0i64; - let mut drs = vec![0i64; ranges]; - let mut dw = vec![0f64; self.cover.len()]; - let mut drw = vec![vec![0f64; ranges]; self.cover.len()]; - for (i, (x, x0)) in e.iter().zip(&b.window).enumerate() { - let d = x.score as i64 - x0.score as i64; - let du = x.unit - x0.unit; - dn += d; - let m = self.in_range[i]; - for (r, v) in drs.iter_mut().enumerate() { - if m & (1 << r) != 0 { - *v += d; - } - } - for (k, cover) in self.cover.iter().enumerate() { - if cover[i] { - dw[k] += du; - for (r, v) in drw[k].iter_mut().enumerate() { - if m & (1 << r) != 0 { - *v += du; - } - } - } - } - } - let mut ds = dn; - for (r, info) in self.infos.iter().enumerate() { - ds += (b.range_scores[r] + drs[r]) * info.rank_bonus_percent / 100 - b.rank_bonuses[r]; - } - let mut w = 0f64; - for k in 0..dw.len() { - w += dw[k]; - for (r, info) in self.infos.iter().enumerate() { - w += info.rank_bonus_percent as f64 / 100.0 * drw[k][r]; - } - } - total += (ds as f64 + w) / POWER as f64; - } - let v = total / self.base.len().max(1) as f64; - self.evaluations += 1; - self.cache.insert(state.to_vec(), v); - Ok(v) - } - - /// The best feasible state among `candidates` (the first on ties), with its value. - fn best_of(&mut self, candidates: Vec>) -> Result, f64)>, Error> { - let mut best: Option<(Vec, f64)> = None; - for c in candidates { - if self.assign(&c).is_none() { - continue; - } - let v = self.value(&c)?; - if best.as_ref().is_none_or(|b| v > b.1 + 1e-12) { - best = Some((c, v)); - } - } - Ok(best) - } - - /// Greedy fill, then coordinate descent: the chosen state and its screened value. - pub fn run(&mut self) -> Result<(Vec, f64), Error> { - let mut state: Vec = Vec::new(); - let mut value = 0f64; - for _ in 0..5 { - let cands: Vec> = (0..self.opts.len()) - .map(|o| { - let mut s = state.clone(); - s.push(o); - s - }) - .collect(); - let (s, v) = - self.best_of(cands)?.ok_or_else(|| Error::Domain("no five members of different characters".into()))?; - state = s; - value = v; - } - for _ in 0..MAX_SWEEPS { - let before = value; - for j in 0..state.len() { - let cands: Vec> = (0..self.opts.len()) - .filter(|&o| o != state[j]) - .map(|o| { - let mut s = state.clone(); - s[j] = o; - s - }) - .collect(); - if let Some((s, v)) = self.best_of(cands)? - && v > value + 1e-12 - { - state = s; - value = v; - } - } - // two performers swap snaps, each keeping its member class (any band condition result it gives) - for a in 0..state.len() { - for b in a + 1..state.len() { - let (oa, ob) = (&self.opts[state[a]], &self.opts[state[b]]); - if oa.snap == ob.snap { - continue; - } - let with = |class: usize, snap: Option| -> Vec { - (0..self.opts.len()) - .filter(|&o| self.opts[o].class == class && self.opts[o].snap == snap) - .collect() - }; - let (na, nb) = (with(oa.class, ob.snap), with(ob.class, oa.snap)); - let mut cands = Vec::new(); - for &x in &na { - for &y in &nb { - let mut s = state.clone(); - s[a] = x; - s[b] = y; - cands.push(s); - } - } - if let Some((s, v)) = self.best_of(cands)? - && v > value + 1e-12 - { - state = s; - value = v; - } - } - } - if value <= before + 1e-12 { - break; - } - } - Ok((state, value)) - } -} - -/// A candidate performer of a search, for diagnostics. -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SearchOption { - /// The member class's Gekisou skill; `None`: fillers (a skill of a mission the chart does not play). - pub skill: Option, - pub support: Option, - pub band_match: Option, - pub member_card_ids: Vec, - pub snap_ids: Vec, -} - -impl Search<'_, '_> { - /// The candidates; a state is a list of candidate indices, one per performer (at most five). - pub fn options(&self) -> Vec { - self.opts - .iter() - .map(|o| { - let first = &self.catalog.members[o.cards[0]]; - let support = o.snap.map(|s| self.snaps[s].support); - SearchOption { - skill: self.classes[o.class], - support, - band_match: support.and_then(|s| band_match(self.live.master, s, &first.performer)), - member_card_ids: o.cards.iter().map(|&c| self.catalog.members[c].id).collect(), - snap_ids: o.snap.map_or(Vec::new(), |s| { - self.snaps[s].snaps.iter().map(|&i| self.catalog.snaps[i].id).collect() - }), - } - }) - .collect() - } - - /// Whether a state has five different characters and every snap at most once. - pub fn feasible(&self, state: &[usize]) -> bool { - state.len() <= 5 && state.iter().all(|&o| o < self.opts.len()) && self.assign(state).is_some() - } - - /// Formations evaluated so far (cached states not counted again). - pub fn evaluations(&self) -> usize { - self.evaluations - } -} - -/// A chart's search, for [`super::search_probe`]. -pub(super) fn search<'a, 'm>( - live: &'a Live<'m>, - kinds: &'a [Kind], - infos: &'a [RangeInfo], - catalog: &'a Catalog, - seeds: &[SeedStats], -) -> Result, Error> { - Search::new(live, catalog, infos, plain_kind(kinds).map(|p| &kinds[p]), seeds, SCREEN_SEEDS) -} - -/// The score at these ranks: `score - sum_i rankBonus_i + sum_i trunc(rangeScore_i * percent_i(ranks[i]) / 100)`. -fn score_at_ranks(score: i32, ranges: &[GekisouRangeResult], infos: &[RangeInfo], ranks: &[i32]) -> Result { - let mut s = score as i64; - for ((r, info), &rank) in ranges.iter().zip(infos).zip(ranks) { - s += r.range_score as i64 * info.percent(rank)? / 100 - r.rank_bonus as i64; - } - Ok(s as i32) -} - -/// The inputs of a chart's Gekisou skill statistics besides the live: the chart's no-skill seeds, the formation's -/// seeds (the chart's seeds first), whether the ranks follow linearly, the score id and the judged notes. -pub(super) struct GkInputs<'a> { - pub seeds: &'a [SeedStats], - pub gk_seeds: &'a [i32], - pub linear: bool, - pub score_id: i64, - pub judged: i32, -} - -/// The Gekisou skill statistics of a chart. -pub(super) fn gekisou_stats( - live: &Live<'_>, - kinds: &[Kind], - infos: &[RangeInfo], - catalog: &Catalog, - inp: &GkInputs<'_>, -) -> Result { - let plain = plain_kind(kinds); - let mut search = Search::new(live, catalog, infos, plain.map(|p| &kinds[p]), inp.seeds, SCREEN_SEEDS)?; - let (state, screened) = search.run()?; - let cards = search.assign(&state).ok_or_else(|| Error::Game("the chosen formation has no members".into()))?; - - // the formation: its member cards, the snaps of each class in catalog order - let mut next = vec![0usize; search.snaps.len()]; - let mut formation = Vec::with_capacity(5); - let mut performers = Vec::with_capacity(5); - for (&o, &c) in state.iter().zip(&cards) { - let opt = &search.opts[o]; - let m = &catalog.members[c]; - let skill = m.skill.ok_or_else(|| Error::Game(format!("member card {} has no Gekisou skill", m.id)))?; - let mut p = m.performer.clone(); - let (snap_id, support) = match opt.snap { - Some(s) => { - let sc = &search.snaps[s]; - let snap = &catalog.snaps[sc.snaps[next[s]]]; - next[s] += 1; - p.gekisou_support_skills = vec![(sc.support.id, sc.support.level)]; - (Some(snap.id), Some(sc.support)) - } - None => (None, None), - }; - let band = support.and_then(|s| band_match(live.master, s, &m.performer)); - formation.push(FormationSlot { - member_card_id: m.id, - character_id: m.character_id, - band_id: m.band_id, - gekisou_skill: skill, - snap_id, - support_skill: support, - band_match: band, - }); - performers.push(p); - } - let evaluations = search.evaluations; - drop(search); - - let mut rng = Rng(GK_CHECK_SALT ^ inp.score_id as u64); - let mut rank_rng = Rng(GK_RANK_SALT ^ inp.score_id as u64); - let mut out = Vec::with_capacity(inp.gk_seeds.len()); - let mut gain = 0f64; - let figure = |score: i32, w: Option<&Vec>| score as f64 / POWER as f64 + w.map_or(0.0, |w| w.iter().sum()); - for (i, &seed) in inp.gk_seeds.iter().enumerate() { - let s = measure_seed(live, kinds, infos, plain, inp, &performers, seed, &mut rng, &mut rank_rng)?; - let with = figure(s.score, plain.and_then(|p| s.weights[p].as_ref())); - let without = match inp.seeds.get(i) { - Some(b) if b.seed == seed => figure(b.score, plain.map(|p| &b.weights[p])), - _ => { - let (score, w) = no_skill_figure(live, kinds, plain, seed)?; - figure(score, w.as_ref()) - } - }; - gain += with - without; - out.push(s); - } - Ok(GekisouStats { - formation, - objective: gain / inp.gk_seeds.len().max(1) as f64, - screened, - evaluations, - seeds: out, - }) -} - -/// The no-skill score and plain weights of a seed that is not one of the chart's seeds. -fn no_skill_figure( - live: &Live<'_>, - kinds: &[Kind], - plain: Option, - seed: i32, -) -> Result<(i32, Option>), Error> { - let none = vec![None; live.positions]; - let (score, _) = live.run_deck(&live.play, live.master, &[], &none, POWER, seed, None)?; - let Some(p) = plain else { return Ok((score, None)) }; - let unit = kind_factor(kinds[p].effect_type, UNIT_VALUE); - let mut w = Vec::with_capacity(live.positions); - for k in 0..live.positions { - let mut skills = none.clone(); - skills[k] = Some(KIND_SKILL_BASE - p as i64); - let (s, _) = live.run_deck(&live.play, live.measure, &[], &skills, POWER, seed, None)?; - w.push((s as f64 - score as f64) / (POWER as f64 * unit)); - } - Ok((score, Some(w))) -} - -/// The whole-live measurements of the formation on one seed. -#[allow(clippy::too_many_arguments)] -fn measure_seed( - live: &Live<'_>, - kinds: &[Kind], - infos: &[RangeInfo], - plain: Option, - inp: &GkInputs<'_>, - formation: &[Performer], - seed: i32, - rng: &mut Rng, - rank_rng: &mut Rng, -) -> Result { - let g = live.gekisou.as_ref().ok_or_else(|| Error::Input("Gekisou skills without Gekisou".into()))?; - let none = vec![None; live.positions]; - let (score, gk) = live.run_deck(&live.play, live.master, formation, &none, POWER, seed, None)?; - let (score_perfect, gkp) = live.run_deck(&g.perfect, live.master, formation, &none, POWER, seed, None)?; - if gkp.len() != gk.len() || gk.len() != infos.len() { - return Err(Error::Game("the formation's plays have other ranges".into())); - } - let rs = |r: &GekisouRange| r.end_score.wrapping_sub(r.start_score); - let ranges: Vec = gk - .iter() - .zip(&gkp) - .map(|(r, p)| GekisouRangeResult { - range_score: rs(r), - rank_bonus: r.rank_bonus.unwrap_or(0), - range_score_perfect: rs(p), - max_combo: r.max_combo, - just_count: r.just_count, - luck_points: r.luck_points, - lot_results: r.lot_results, - }) - .collect(); - let mut weights: Vec>> = vec![None; kinds.len()]; - let mut range_weights: Vec>>> = vec![None; kinds.len()]; - if let Some(p) = plain { - let unit = kind_factor(kinds[p].effect_type, UNIT_VALUE); - let mut w = Vec::with_capacity(live.positions); - let mut rw = Vec::with_capacity(live.positions); - for k in 0..live.positions { - let mut skills = none.clone(); - skills[k] = Some(KIND_SKILL_BASE - p as i64); - let (s, gks) = live.run_deck(&live.play, live.measure, formation, &skills, POWER, seed, None)?; - w.push((s as f64 - score as f64) / (POWER as f64 * unit)); - rw.push( - gks.iter() - .zip(&ranges) - .map(|(r, r0)| (rs(r) as f64 - r0.range_score as f64) / (POWER as f64 * unit)) - .collect(), - ); - } - weights[p] = Some(w); - range_weights[p] = Some(rw); - } - let range_weights = inp.linear.then_some(range_weights); - - // the check deck: one of the plain kind's master values (or no skill) at each position, at another power - let usable: Vec = plain.into_iter().collect(); - let (deck, rows) = check_deck(kinds, &usable, live.positions, rng); - let master = Live::master_with(live.master, &rows); - let floors = inp.judged as f64 + MAX_GEKISOU_FEVERS as f64; - let weight = |ki: usize, k: usize| weights[ki].as_ref().map_or(f64::NAN, |w| w[k]); - let base = score as f64 / POWER as f64; - let c = live - .check_with(kinds, &master, formation, &deck, seed, None, base, weight, floors, 0.0)? - .within(|| format!("Gekisou skills, seed {seed}"))?; - let check = Check { deck, exact: c.exact, predicted: c.predicted, bound: c.bound }; - - let mut rank_check = None; - if let Some(rw) = range_weights.as_ref().filter(|_| !ranges.is_empty()) { - let ranks: Vec = infos.iter().map(|_| 1 + rank_rng.below(RANKS) as i32).collect(); - let confirmations: Vec<(i32, i64)> = - ranks.iter().zip(infos).map(|(&r, info)| Ok((r, info.percent(r)?))).collect::>()?; - let base = score_at_ranks(score, &ranges, infos, &ranks)? as f64 / POWER as f64; - let mut d = Vec::with_capacity(infos.len()); - for (info, &rank) in infos.iter().zip(&ranks) { - d.push((info.percent(rank)? - info.percent(1)?) as f64 / 100.0); - } - let weight = |ki: usize, k: usize| match (&weights[ki], &rw[ki]) { - (Some(w), Some(rw)) => w[k] + rw[k].iter().zip(&d).map(|(&r, &d)| r * d).sum::(), - _ => f64::NAN, - }; - // each range moves a weight by the difference of two floored bonuses from its rank 1 value: under 2 points - let slack = 2.0 * infos.len() as f64; - let c = live - .check_with( - kinds, - &master, - formation, - &check.deck, - seed, - Some(&confirmations), - base, - weight, - floors, - slack, - )? - .within(|| format!("Gekisou skills, seed {seed} at ranks {ranks:?}"))?; - rank_check = Some(RankCheck { ranks, exact: c.exact, predicted: c.predicted, bound: c.bound }); - } - Ok(GekisouSeed { seed, score, score_perfect, ranges, weights, range_weights, check, rank_check }) -} diff --git a/src/live/full/mod.rs b/src/live/full/mod.rs index 45e82a6..929b89f 100644 --- a/src/live/full/mod.rs +++ b/src/live/full/mod.rs @@ -49,9 +49,9 @@ pub use gekisou::{ mod life; mod raw_runtime; pub use raw_runtime::{RELAX_TARGET_JUDGEMENTS, RawJudgedNote, RawJudgementRuntime}; +mod range_frames; mod scorecalc; -mod window; -pub use window::{NoteEval, RangeFrames, WindowNote, window_note_scores}; +pub use range_frames::RangeFrames; use std::collections::{HashMap, HashSet, VecDeque}; diff --git a/src/live/full/range_frames.rs b/src/live/full/range_frames.rs new file mode 100644 index 0000000..479926c --- /dev/null +++ b/src/live/full/range_frames.rs @@ -0,0 +1,65 @@ +//! The frames of a play where each Gekisou range changes state. + +use serde::Serialize; + +use super::gekisou::{S_COMPLETE, S_DELAY, S_END, S_FINISH, S_STANDBY, S_START}; +use super::{LiveModel, LivePlay}; +use crate::error::Error; + +/// Frame indices (into the play's frames) where a Gekisou range changed its state. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RangeFrames { + /// Wait -> Standby (`None` when the range never waited: it started in the first frame). + pub standby: Option, + /// The fever's start: Start (the next frame is Playing). + pub start: usize, + /// The fever's end: End. + pub end: usize, + /// The end delay (the longest judgement window after the end, counted with the frame delta times): Delay. + pub delay: usize, + /// 500 ms later: Complete (the range's scores are taken and its rank bonus added). + pub complete: usize, + /// The next frame: Finish (the luck rush ends). + pub finish: usize, +} + +impl LiveModel { + /// Plays every frame of `play` (delta times `dt`, the play's seed) and records the frames where each Gekisou + /// range changed state. + pub fn record_range_frames(&mut self, play: &LivePlay, dt: &[f32]) -> Result, Error> { + if dt.len() != play.frames.len() { + return Err(Error::Input("one delta time per frame".into())); + } + self.random.set_seed(play.base_seed); + let n = self.gk.as_ref().map_or(0, |g| g.ctrl.ranges.len()); + let mut seen = vec![[None::; 9]; n]; + for (i, (f, &d)) in play.frames.iter().zip(dt).enumerate() { + self.frame_timed(f.time_ms, &f.judged, d)?; + let g = self.gk.as_ref().ok_or_else(|| Error::Input("range frames without Gekisou".into()))?; + for &idx in &g.ctrl.state_updates { + let s = g.ctrl.states[idx].state as usize; + if let Some(slot) = seen[idx].get_mut(s) { + slot.get_or_insert(i); + } + } + } + seen.iter() + .enumerate() + .map(|(i, s)| { + let need = |state: u8| { + s[state as usize] + .ok_or_else(|| Error::Game(format!("Gekisou range {i} never reached state {state}"))) + }; + Ok(RangeFrames { + standby: s[S_STANDBY as usize], + start: need(S_START)?, + end: need(S_END)?, + delay: need(S_DELAY)?, + complete: need(S_COMPLETE)?, + finish: need(S_FINISH)?, + }) + }) + .collect() + } +} diff --git a/src/live/full/scorecalc.rs b/src/live/full/scorecalc.rs index 3d25635..481482f 100644 --- a/src/live/full/scorecalc.rs +++ b/src/live/full/scorecalc.rs @@ -142,17 +142,6 @@ impl IncrementalCalculator { self.factors[f].push(cmd); } - /// Every factor command filed so far, frame by frame and in filing order within a frame (the order the frames - /// apply them after sorting by time and owner, a stable sort). - pub(crate) fn factor_commands(&self) -> impl Iterator { - self.factors.iter().flatten() - } - - /// The last frame (exclusive) of the frame table: commands and notes beyond it are filed in its last frame. - pub(crate) fn max_frame(&self) -> i32 { - self.max_frame - } - /// Sets the fixed score filed by the next calculation (only the last one set counts). pub(crate) fn add_fixed(&mut self, time_ms: i32, score: i32) { self.pending_fixed = Some((time_ms, score)); @@ -242,10 +231,3 @@ impl IncrementalCalculator { Ok(()) } } - -impl IncrementalCalculator { - /// `(note id, chart time, last score)` of every note filed so far. - pub(crate) fn note_results(&self) -> Vec<(i32, i32, i32)> { - self.notes.iter().flatten().map(|n| (n.note_id, n.time_ms, n.added)).collect() - } -} diff --git a/src/live/full/window.rs b/src/live/full/window.rs deleted file mode 100644 index b17bc26..0000000 --- a/src/live/full/window.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! Gekisou windows: the frames of a play where Gekisou skills act, the simulation driven over those frames only, and -//! the note scores of a window recomputed from the factor commands and the Gekisou combo. -//! -//! A Gekisou (support) skill triggers only while a range of its mission is concerned (a state change of such a range -//! in the frame, or the range playing), and everything it changes ends by the range's Finish: its combo and Just -//! bonuses, cumulative rules and luck factors act on the range's own counts; its score factors end at the latest when -//! the range completes and the luck rush it raises ends at the Finish (`GekisouController.BeforeUpdate`). So a live -//! with Gekisou skills differs from the live without them only in the notes judged from a range's Start frame to its -//! Finish frame (the range's window), and the simulation can play just those frames: every random draw of the luck -//! lottery and of the probability condition 4011 happens inside them, in the same order. -//! -//! The window's note scores follow from the commands filed during the window: every frame is executed for the last -//! time after every command filed in it or earlier (a command filed in an executed frame undoes and executes again -//! from that frame, `IncrementalCalculator`), so a note's final score is the one of the time-ordered command list -//! (frames in order; in a frame factor commands by time and owner, a note after the commands of its time) with the -//! final Gekisou combo of its range (the combo is frozen once the range is past its end delay, and the range's notes -//! are scored again when it completes). The undo of a frame subtracts the float sum of the changes it applied, so the -//! game's float state can differ from the ordered sum in the last bits (see [`super::scorecalc`]); the recomputed -//! scores ignore that. - -use serde::Serialize; - -use super::gekisou::{S_COMPLETE, S_DELAY, S_END, S_FINISH, S_STANDBY, S_START}; -use super::{LiveModel, LiveNote, LivePlay}; -use crate::error::Error; -use crate::live::score::{GekisouComboInfo, LiveScoreCalculator, ScoreFactorState, get_frame, get_luck_factor_percent}; -use crate::live::skill::{FactorCommand, apply_factor}; -use crate::num::min_ignoring_nan; - -/// Frame indices (into the play's frames) where a Gekisou range changed its state. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RangeFrames { - /// Wait -> Standby (`None` when the range never waited: it started in the first frame). - pub standby: Option, - /// The fever's start: Start (the next frame is Playing). - pub start: usize, - /// The fever's end: End. - pub end: usize, - /// The end delay (the longest judgement window after the end, counted with the frame delta times): Delay. - pub delay: usize, - /// 500 ms later: Complete (the range's scores are taken and its rank bonus added). - pub complete: usize, - /// The next frame: Finish (the luck rush ends). - pub finish: usize, -} - -/// A note of a window: the chart note, the combo the score reads for it (the live's combo at its time) and its score -/// type after the judgement conversion. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct WindowNote { - pub note: LiveNote, - pub combo: i32, - pub score_type: i32, -} - -/// A window note's score and its derivatives by the factors a live skill adds. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct NoteEval { - pub score: i32, - /// The score per unit of note score factor (effect types 2000 / 2005, and 2004 when the score type is a target), - /// before the floors: `base * judgement% * combo factor * luck% / notes`. - pub unit: f64, - /// The score per unit of combo score factor (2002): `base * judgement% * gekisou combo factor * score factor * - /// luck% / notes`. - pub unit_combo: f64, -} - -impl LiveModel { - /// Plays every frame of `play` (delta times `dt`) and records the frames where each Gekisou range changed state. - pub fn record_range_frames(&mut self, play: &LivePlay, dt: &[f32]) -> Result, Error> { - if dt.len() != play.frames.len() { - return Err(Error::Input("one delta time per frame".into())); - } - self.random.set_seed(play.base_seed); - let n = self.gk.as_ref().map_or(0, |g| g.ctrl.ranges.len()); - let mut seen = vec![[None::; 9]; n]; - for (i, (f, &d)) in play.frames.iter().zip(dt).enumerate() { - self.frame_timed(f.time_ms, &f.judged, d)?; - let g = self.gk.as_ref().ok_or_else(|| Error::Input("range frames without Gekisou".into()))?; - for &idx in &g.ctrl.state_updates { - let s = g.ctrl.states[idx].state as usize; - if let Some(slot) = seen[idx].get_mut(s) { - slot.get_or_insert(i); - } - } - } - seen.iter() - .enumerate() - .map(|(i, s)| { - let need = |state: u8| { - s[state as usize] - .ok_or_else(|| Error::Game(format!("Gekisou range {i} never reached state {state}"))) - }; - Ok(RangeFrames { - standby: s[S_STANDBY as usize], - start: need(S_START)?, - end: need(S_END)?, - delay: need(S_DELAY)?, - complete: need(S_COMPLETE)?, - finish: need(S_FINISH)?, - }) - }) - .collect() - } - - /// Plays only the frames `frames` of `play` (indices, ascending) with their delta times and the random seed `seed` (the - /// play's own seed is not read); returns the judged notes - /// of those frames in order, `(note id, judgement after the conversion)`. - pub fn run_frames( - &mut self, - play: &LivePlay, - seed: i32, - dt: &[f32], - frames: &[usize], - ) -> Result, Error> { - if dt.len() != play.frames.len() { - return Err(Error::Input("one delta time per frame".into())); - } - if frames.windows(2).any(|w| w[1] <= w[0]) { - return Err(Error::Input("window frames must ascend".into())); - } - self.random.set_seed(seed); - let mut judged = Vec::new(); - for &i in frames { - let f = play.frames.get(i).ok_or_else(|| Error::Input(format!("window frame {i} out of range")))?; - self.frame_timed(f.time_ms, &f.judged, dt[i])?; - judged.extend(self.judged.iter().map(|&(id, j, _)| (id, j))); - } - Ok(judged) - } - - /// The Gekisou combo the score reads at a chart time (`None` outside a combo range or without Gekisou). - pub fn gekisou_combo_at(&self, time_ms: i32) -> Option { - self.gk.as_ref().and_then(|g| g.ctrl.gekisou_combo(time_ms)) - } - - /// Every factor command filed so far, frame by frame, in filing order within a frame. - pub fn factor_commands(&self) -> Vec { - self.score.factor_commands().copied().collect() - } - - /// The score calculator with the live's initial factor state (only the band total power). - pub fn initial_calculator(&self, total_power: i32) -> LiveScoreCalculator { - let mut c = self.score.calc.clone(); - c.state = ScoreFactorState::new(total_power); - c - } - - /// The last frame (exclusive) of the score's frame table. - pub fn score_max_frame(&self) -> i32 { - self.score.max_frame() - } -} - -/// The scores of a window's notes (in their order) from the factor commands filed in the window (in filing order): -/// frames in order; in a frame the commands by (time, owner), stably, each before the notes of its time and later, -/// the notes by (time, note id). `gekisou` gives the Gekisou combo; the life is full. -pub fn window_note_scores( - calc: &LiveScoreCalculator, - max_frame: i32, - notes: &[WindowNote], - commands: &[FactorCommand], - gekisou: &dyn GekisouComboInfo, -) -> Result, Error> { - let frame = |t: i32| get_frame(t).min(max_frame.wrapping_sub(1)); - // (frame, time, 0 command / 1 note, owner or note id, index) - let mut items: Vec<(i32, i32, u8, i32, usize)> = Vec::with_capacity(notes.len() + commands.len()); - for (i, c) in commands.iter().enumerate() { - items.push((frame(c.time_ms), c.time_ms, 0, c.owner_id, i)); - } - for (i, n) in notes.iter().enumerate() { - items.push((frame(n.note.time_ms), n.note.time_ms, 1, n.note.note_id, i)); - } - items.sort_by_key(|x| (x.0, x.1, x.2, x.3)); - let mut calc = calc.clone(); - let mut out = vec![NoteEval { score: 0, unit: 0.0, unit_combo: 0.0 }; notes.len()]; - for (_, _, kind, _, i) in items { - if kind == 0 { - apply_factor(&mut calc.state, &commands[i]); - continue; - } - let n = ¬es[i]; - let score = - calc.note_score(n.combo, 1000, n.note.time_ms, n.note.note_operate_type, n.score_type, Some(gekisou))?; - let (unit, unit_combo) = note_units(&calc, n, gekisou)?; - out[i] = NoteEval { score, unit, unit_combo }; - } - Ok(out) -} - -/// The score of a note per unit of note score factor and of combo score factor, before the floors. -fn note_units(calc: &LiveScoreCalculator, n: &WindowNote, gekisou: &dyn GekisouComboInfo) -> Result<(f64, f64), Error> { - let np = *calc - .note_factor_percent - .get(&n.note.note_operate_type) - .ok_or_else(|| Error::Game(format!("note type {} has no score percent", n.note.note_operate_type)))?; - let jp = *calc - .judgement_score_factor_percent - .get(&n.score_type) - .ok_or_else(|| Error::Game(format!("score type {} has no score percent", n.score_type)))?; - let t = (calc.score_adjustment_factor * calc.state.band_total_power as f32) * calc.music_difficulty_factor; - let a = (np as f32 / 100f32) * t; - let b = (jp as f32 / 100f32) * a; - let gk = calc.gekisou_combo_bonus_factor(Some(gekisou), n.note.time_ms)?; - let cum = match &calc.combo_table { - None => 0f32, - Some(tb) => tb.get_cumulative_factor(crate::live::score::COMBO, n.combo)?, - }; - let combo_factor = gk * (calc.state.combo_score_up + (min_ignoring_nan(cum, 1f32) + 1f32)); - let score_up = calc.state.note_score_up + calc.state.judgement_factor(n.score_type); - let luck = get_luck_factor_percent(calc.state.added_luck_bonus) as f64 / 100.0; - let per = f64::from(b) * luck / f64::from(calc.converted_note_count) - * f64::from(calc.event_bonus_factor) - * f64::from(calc.assist_factor); - Ok((per * f64::from(combo_factor), per * f64::from(gk) * f64::from(score_up))) -} - -impl LiveModel { - /// `(note id, chart time, last score)` of every note scored so far. - pub fn note_results(&self) -> Vec<(i32, i32, i32)> { - self.score.note_results() - } -} diff --git a/src/master.rs b/src/master.rs index 691b602..855eac5 100644 --- a/src/master.rs +++ b/src/master.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use serde::de::DeserializeOwned; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Deserializer}; use crate::error::Error; @@ -30,7 +30,7 @@ fn f32_text<'de, D: Deserializer<'de>>(d: D) -> Result { macro_rules! row { ($(#[$m:meta])* $name:ident { $($(#[$fm:meta])* $f:ident : $t:ty = $json:literal),* $(,)? }) => { $(#[$m])* - #[derive(Clone, Debug, Default, Deserialize, Serialize)] + #[derive(Clone, Debug, Default, Deserialize)] #[serde(default)] pub struct $name { $($(#[$fm])* #[serde(rename = $json)] pub $f: $t,)* @@ -208,7 +208,7 @@ row!( ); /// `MasterSkillCondition`. -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize)] pub struct SkillConditionRow { #[serde(rename = "_id", default)] pub id: i64, From 94281d18ce50c991d7c8a082d2a32a4522050454 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 30 Sep 2026 04:21:43 +0800 Subject: [PATCH 07/11] fix: classify random dependencies and reject invalid aptitude checks Co-Authored-By: Claude Code --- src/chartstats.rs | 23 +++++++++++++++++++---- src/chartstats/aptitude.rs | 22 ++++++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/chartstats.rs b/src/chartstats.rs index 268876b..8660d3d 100644 --- a/src/chartstats.rs +++ b/src/chartstats.rs @@ -454,6 +454,18 @@ pub struct Options { pub aptitude: Option, } +impl Options { + fn validate(&self) -> Result<(), Error> { + if self.seeds == 0 { + return Err(Error::Input("an empty seed set".into())); + } + if self.aptitude.is_some_and(|a| a.max_seeds < 2 || a.cross_seeds == 0) { + return Err(Error::Input("aptitude needs at least two maximum seeds and one cross seed".into())); + } + Ok(()) + } +} + impl Default for Options { fn default() -> Options { Options { seeds: GEKISOU_SEEDS, aptitude: Some(AptitudeOptions::default()) } @@ -540,7 +552,11 @@ struct Checked { impl Checked { fn within(self, what: impl FnOnce() -> String) -> Result { - if (self.exact as f64 - self.predicted).abs() > self.bound { + if !self.predicted.is_finite() + || !self.bound.is_finite() + || self.bound < 0.0 + || (self.exact as f64 - self.predicted).abs() > self.bound + { return Err(Error::Domain(format!( "{}: the check deck scores {}, the chart statistics predict {:.1} (bound {:.1})", what(), @@ -839,6 +855,7 @@ pub fn chart_stats_with( kinds: &[Kind], options: &Options, ) -> Result { + options.validate()?; let seeds = options.seeds; let settings = LiveScoreSettings::from_master(master)?; let c: Chart = chart.chart(&settings)?; @@ -997,9 +1014,7 @@ pub fn document(data: &DeckData, seeds: Option) -> Result Result { - if options.seeds == 0 || options.aptitude.as_ref().is_some_and(|a| a.max_seeds == 0) { - return Err(Error::Input("an empty seed set".into())); - } + options.validate()?; let kinds = kinds(&data.master); let mut charts = Vec::with_capacity(data.charts.len()); for c in &data.charts { diff --git a/src/chartstats/aptitude.rs b/src/chartstats/aptitude.rs index 5291a51..7136922 100644 --- a/src/chartstats/aptitude.rs +++ b/src/chartstats/aptitude.rs @@ -721,6 +721,9 @@ pub(super) fn chart_aptitude( shapes: &[Shape], inp: &Inputs<'_>, ) -> Result { + if inp.options.max_seeds < 2 || inp.options.cross_seeds == 0 { + return Err(Error::Input("aptitude needs at least two maximum seeds and one cross seed".into())); + } let plain = plain_kind(kinds); let mut m = Measure { live, @@ -743,12 +746,23 @@ pub(super) fn chart_aptitude( let bands: Vec> = if shape.band_condition { vec![Some(true), Some(false)] } else { vec![None] }; for band in bands { let p = performer(&m.master, shape, band)?; - // deterministic when the increments agree on the test seeds + // Equal observations are only a regression check, never proof of determinism. A luck + // range can change score factors even for another mission's skill (including its tail). + let random_dependency = missions.contains(&2) + || shape.effects.iter().any(|e| { + (11000..=11005).contains(&e.effect_type) + || [&e.trigger, &e.condition, &e.release, &e.reset] + .iter() + .flat_map(|g| g.iter().flatten()) + .any(|c| c.condition_type == 4011) + }); let mut test = Vec::with_capacity(test_seeds.len()); - for &s in &test_seeds { - test.push(m.sample(&p, s, false)?.values); + if !random_dependency { + for &s in &test_seeds { + test.push(m.sample(&p, s, false)?.values); + } } - let deterministic = test.windows(2).all(|w| w[0] == w[1]); + let deterministic = !random_dependency && test.windows(2).all(|w| w[0] == w[1]); let mut samples: Vec = Vec::new(); let mut met = true; if deterministic { From 5e28b136fda82303fae63243d183483203ecf239 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 30 Sep 2026 04:23:29 +0800 Subject: [PATCH 08/11] test: cover Gekisou aptitude shapes, paired measurements and CLI contracts Add synthetic fixtures for band variants, empty hosts, score/tail moments, seed stopping, mission gates and null outputs. Document single-skill limits and the unchanged baseline. The rare-probability regression intentionally exposes the pending structural-randomness fix. Co-Authored-By: Claude Code --- README.en.md | 76 ++++- README.md | 53 +++- src/bin/ournotes-deck.rs | 9 +- src/chartstats/aptitude.rs | 11 +- tests/chartstats.rs | 616 ++++++++++++++++++++++++++++++++++++- tests/common/mod.rs | 6 +- 6 files changed, 743 insertions(+), 28 deletions(-) diff --git a/README.en.md b/README.en.md index 98e646d..7b3fe59 100644 --- a/README.en.md +++ b/README.en.md @@ -131,18 +131,23 @@ Constraints: `--leader ID`, `--include ID,...`, `--exclude ID,...`, `--exclude-s Chart statistics: ```sh -ournotes-deck chart-stats --data deck-data.json [--seeds 8] -o chart-stats.json +ournotes-deck chart-stats --data deck-data.json [--seeds 8] [--charts ID,...] [--jobs N] -o chart-stats.json ``` measures every chart's deck-independent numbers on the whole-live simulation (`ournotes-deck.chart-stats/2`) in two scenarios: Gekisou on (`seeds`, as a Battle Live plays) and Gekisou off (`offSeeds`, as a solo live such as Free -Live or Challenge Live plays). +Live or Challenge Live plays). `--charts` measures the listed score ids only (in the file's order; ids the file does +not have are ignored); `--jobs N` measures N charts at once and writes the same document as one at a time. Without +`-o` the document goes to the standard output. With Gekisou on, the play is the theoretical best play with Gekisou: every note judged at its time, Just inside the -Just-count ranges and Perfect elsewhere, rank 1 in every range. Per seed: the exact no-skill score and the Gekisou -ranges' results, and for every score-up kind of the master (2000 / 2002 / 2004 / 2005 rows grouped by type, -duration, targets and conditions, see `kinds`) at every performance position the score gained at factor 1 per unit -of deck power (`weights[kind][k]`). A deck scores about `P × (score / power + Σ factor_k × weights[kind_k][k])`; +Just-count ranges and Perfect elsewhere, rank 1 in every range. Per seed: the exact no-skill score; the Gekisou +ranges' results (`ranges`: the range score, the rank 1 bonus, the largest Gekisou combo `maxCombo`, the Just count +`justCount`, the luck points `luckPoints` and the lottery results `lotResults`; the combo, Just and luck missions +rank by `maxCombo`, `justCount` and `luckPoints`, and without skills the luck points come from the lottery of the +luck ranges alone, 0 elsewhere); and for every score-up kind of the master (2000 / 2002 / 2004 / 2005 rows grouped by +type, duration, targets and conditions, see `kinds`) at every performance position the score gained at factor 1 per +unit of deck power (`weights[kind][k]`). A deck scores about `P × (score / power + Σ factor_k × weights[kind_k][k])`; every seed checks this on a random deck of the master's own values at another power and fails beyond the flooring bound. Charts with a luck range are given on the first N published seeds (`--seeds`, default 8), which is not a native expectation; a chart with more than three fevers, where the game fails when the fourth starts, is @@ -162,6 +167,65 @@ With Gekisou off, the play is the theoretical best play (every note Perfect at i Gekisou combo or rank bonus; `score`, `weights` and a check as above. A kind whose conditions read the Gekisou state cannot play without Gekisou and has null weights. + +### Gekisou skill aptitude + +Statistics also include each skill's aptitude for a chart, without selecting a best formation or changing `seeds` +or `offSeeds`. The file-level `gekisouAptitude` holds shapes and measurement rules; each chart's +`charts[].gekisouAptitude` holds range `factors` and the `variants` of its missions. It is null for a chart with no +Gekisou range, one unplayable with Gekisou, or a master without measurable skills. These are additional fields; +the format remains `ournotes-deck.chart-stats/2`. + +```sh +ournotes-deck chart-stats --data deck-data.json --aptitude-max-seeds 128 --aptitude-cross-seeds 32 -o stats.json +ournotes-deck chart-stats --data deck-data.json --no-gekisou-aptitude -o baseline.json +``` + +- `--aptitude-max-seeds N`: at most N seeds for a random increment, default 1024; stop earlier at the SE target. +- `--aptitude-cross-seeds N`: at most the first N seeds for ordinary skill cross terms, default 64. +- `--no-gekisou-aptitude`: skip aptitude measurement; both file-level and per-chart `gekisouAptitude` are null. + Existing statistics are still produced. + +Shapes deduplicate by source, mission and effect parameters. Member skills use their highest level; support skills +use the level at the snap's highest rank, not necessarily the highest level in the effect table. Support skills +that differ only in their band targets share a shape, preserving `skills[].memberTargetIds` / `bandIds`, and are +measured both with `bandMatch: true` and `false`. Each support skill has a **synthetic, effect-free member Gekisou +skill** of its own mission as host, never a real card whose effects could contaminate the increment. Each run plays +one member or support skill alone through the whole-live engine. + +`score`, `scorePerfect`, `tail` and range increments are `[mean, standard error of the mean]`: with-skill minus +without-skill on the same seed, at `model.power`. `tail = Δscore − Σ(ΔrangeScore + ΔrankBonus)` includes gains outside +the range score frames, such as effects persisting after the range ends. `factors` lists range note counts, +entering combos and baseline lottery counts. `weights` gives changes in the plain ordinary kind's weight at each +position; `rangeWeights` gives the corresponding range changes, not full formation weights. Both are null without +a plain kind. Each variant's `check` uses its first measured seed, random ranks and an ordinary-skill deck at +another power to validate the linear prediction; exceeding its flooring bound fails the measurement. + +Random increments use batches of 32, 64, 128, 256, 512 and 1024 seeds, capped by the option, until Δscore's SE is at +most `max(1% × |mean increment|, 0.1% × mean no-skill score)`. At the cap, `seTargetMet` reports whether this target +was met. Deterministic increments report one seed and zero SE; four identical samples alone cannot establish that +a random skill is deterministic. The seed mean is not the game's expectation: its seed law is unknown, and SE +does not measure model error. Cross terms can use fewer seeds, reported as `crossSeeds` per variant. + +**Model limits:** + +- Only `battleLiveScore` changes, not the separately reported `soloScore`; Free Live has no aptitude gain. +- **Do not add increments of multiple skills.** Gekisou combo saturation, luck gauge/rush interactions and + Just-count-dependent support triggers can all break additivity. +- The theoretical best play has no Great or Miss: combo protection 12004, Great-to-Perfect 12006 and judgement + window extension 4004 have zero effect here. Just-count effects 13000/13002 and luck-point effect 11002 can change + range indicators without increasing score. Shapes of other missions are gated off and omitted on this chart. +- Ordinary-skill factors and rank changes use the linear formula, with rank rounding differences checked by + `check`. Below 100% Just, interpolation of the no-ordinary-skill increment between Just and Perfect plays is + approximate: conversion 13005, per-Just support 2001 and Just-count effect 13002 are nonlinear. Perfect-play + cross weights are not measured, so full aptitude with nonzero ordinary skills is unavailable below 100% Just. + Scaling by `1 − 0.2q` for Great proportion q is also approximate. + +Library callers can use `chart_stats_with` / `document_with` with +`Options { seeds, aptitude: Some(AptitudeOptions { max_seeds, cross_seeds }) }`; `aptitude: None` disables it. +A `DeckData` without charts still produces the shape header, also available through +`aptitude_header(master, kinds, options)`. + ## Tests `cargo test` runs the unit tests, reads synthetic deck data files and compares the search with exhaustive diff --git a/README.md b/README.md index 4a24692..e25a9c4 100644 --- a/README.md +++ b/README.md @@ -110,14 +110,17 @@ ournotes-deck live --data deck-data.json --roster box.json --score SCORE_ID --e 谱面统计: ```sh -ournotes-deck chart-stats --data deck-data.json [--seeds 8] -o chart-stats.json +ournotes-deck chart-stats --data deck-data.json [--seeds 8] [--charts ID,...] [--jobs N] -o chart-stats.json ``` 在整场模拟上实测每张谱面与卡组无关的量(`ournotes-deck.chart-stats/2`),分两种场景:激走开启(`seeds`,撃奏ライブ -的打法)与激走关闭(`offSeeds`,自由 Live、挑战 Live 等单人 Live 的打法)。 +的打法)与激走关闭(`offSeeds`,自由 Live、挑战 Live 等单人 Live 的打法)。`--charts` 只测列出的 score id(按文件中的 +顺序输出,文件里没有的 id 忽略);`--jobs N` 同时测 N 张谱面,输出与逐张测量完全相同。不给 `-o` 时输出到标准输出。 激走开启时打法为激走理论最佳:每个音符按准点判定,Just 任务区间内为 Just,其余为 Perfect,每个区间取名次 1。每个 -种子给出:无技能的精确得分与各激走区间结果;master 中每种加分效果(2000 / 2002 / 2004 / 2005,按类型、时长、目标、 +种子给出:无技能的精确得分;各激走区间的结果(`ranges`:区间得分、名次 1 加成、最大激走连击 `maxCombo`、Just 数 +`justCount`、幸运点数 `luckPoints`、抽签结果 `lotResults`;前三个计数分别是连击、Just、幸运任务的名次指标;无技能时幸运点数 +只来自幸运区间的抽签,其他区间为 0);master 中每种加分效果(2000 / 2002 / 2004 / 2005,按类型、时长、目标、 条件分组,见 `kinds`)在每个演出位上因子为 1 时的得分增量除以综合力(`weights[kind][k]`)。卡组得分约为 `P × (score / power + Σ factor_k × weights[kind_k][k])`,每个种子都用 master 真实数值的随机卡组在另一综合力下实跑校验, 偏差超出取整上限即报错。有幸运区间的谱面按前 N 个发布种子给出(`--seeds`,默认 8),这不是原生期望;超过三段 fever @@ -133,6 +136,50 @@ ournotes-deck chart-stats --data deck-data.json [--seeds 8] -o chart-stats.json 激走关闭时打法为理论最佳(每个音符准点 Perfect),种子 0,没有 Just、幸运、激走连击和名次加成;给出同形的 `score`、 `weights` 与校验。条件读取激走状态的效果种类在激走关闭时无法演出,其权重为 null。 + +### 激走技能适性 + +统计默认还给出单技能适性,不选择最佳编成,也不改变上面的 `seeds` / `offSeeds`。文件级 `gekisouAptitude` 是形状表和 +测量规则;每谱 `charts[].gekisouAptitude` 是谱面因子 `factors` 与该谱任务对应的变体 `variants`。没有激走区间、不能开 +激走或没有可测技能时,每谱字段为 null。格式仍是 `ournotes-deck.chart-stats/2`,这些都是新增字段。 + +```sh +ournotes-deck chart-stats --data deck-data.json --aptitude-max-seeds 128 --aptitude-cross-seeds 32 -o stats.json +ournotes-deck chart-stats --data deck-data.json --no-gekisou-aptitude -o baseline.json +``` + +- `--aptitude-max-seeds N`:随机增量最多测 N 个种子,默认 1024;标准误足够小时提前停止。 +- `--aptitude-cross-seeds N`:普通技能交叉项最多测前 N 个种子,默认 64。 +- `--no-gekisou-aptitude`:跳过适性测量,文件级和每谱的 `gekisouAptitude` 都为 null,原有统计照常输出。 + +形状按来源、任务与效果参数去重。成员技能取该技能的最高等级;小卡技能取最高突破对应的等级,不直接取效果表最高等级。 +支援技能仅差乐队目标时归为同一形状,保留 `skills[].memberTargetIds` / `bandIds`;每谱分别测 `bandMatch: true/false`。 +支援技能的宿主统一使用同任务的**合成空激走技能**,不借真卡,不混入成员技能收益。每次只带一个成员或支援技能,整局实跑。 + +变体的 `score`、`scorePerfect`、`tail`、区间增量等均为 `[均值, 均值标准误]`,是同种子下「带技能 − 不带技能」的差, +综合力固定为 `model.power`。`tail = Δscore − Σ(ΔrangeScore + ΔrankBonus)` 表示区间外收益,包含技能延续到区间结束后的 +尾部;`factors` 给出各区间音符数、进入时连击和基线抽签次数,用来解释适性。`weights` 是普通技能 `plainKind` 各位置权重 +的变化,`rangeWeights` 是对应区间权重变化;不是完整编成权重,没有普通 kind 时两者为 null。每个变体的 `check` 用首个 +测量种子、随机名次与随机普通技能卡组,在另一综合力下验证线性预测,超出取整界时报错。 + +随机增量按 32、64、128、256、512、1024 个种子逐级测量(受最大种子参数限制),当 Δscore 的标准误不超过 +`max(增量均值绝对值 × 1%, 无技能总分均值 × 0.1%)` 时停止;到上限仍不满足则 `seTargetMet: false`。确定性增量报一个 +种子、标准误 0;四个种子恰好相等本身不能证明随机技能是确定性的。种子均值不是游戏的期望,真实种子分布未知,标准误也 +不表示模型误差。交叉项可能使用更少种子,见各变体的 `crossSeeds`。 + +**模型边界:** + +- 只影响撃奏ライブ的 `battleLiveScore`,不影响另行上报的 `soloScore`,自由 Live 不加适性收益。 +- 只测单技能,**多个技能增量不能相加**:激走连击封顶、幸运槽与 rush 支援交互、Just 数改变相关支援触发等都会破坏可加性。 +- 理论最佳打法没有 Great / Miss,12004 连击保护、12006 Great→Perfect、4004 判定窗扩大在此为零;13000 / 13002 + Just 数加成与 11002 幸运点数加成可改变区间指标,但不直接增加得分。任务不符的形状因门控为零,不列进该谱变体。 +- 普通技能倍率与名次沿用线性式,名次增量每区间有取整差,由 `check` 验证。Just 率不足 100% 时,仅能给出无普通技能增量的 Just / Perfect 插值估计, + 13005 转换、每 Just 的 2001 支援及 13002 的 Just 数变化不能按比例精确缩放。未测 Perfect 打法的交叉权重,因此普通技能非零时不提供低于 100% Just 的完整适性;Great 比例乘 `1 − 0.2q` 也只是近似。 + +库调用可用 `chart_stats_with` / `document_with` 与 +`Options { seeds, aptitude: Some(AptitudeOptions { max_seeds, cross_seeds }) }`;`aptitude: None` 关闭适性。 +不含谱面的 `DeckData` 也可以生成形状表,或直接调用 `aptitude_header(master, kinds, options)`。 + ## 测试 `cargo test` 运行单元测试,读取合成的 deck data 文件,并在小规模合成卡池上将搜索结果与穷举结果逐项比较。 diff --git a/src/bin/ournotes-deck.rs b/src/bin/ournotes-deck.rs index 39a18bc..ac80fe2 100644 --- a/src/bin/ournotes-deck.rs +++ b/src/bin/ournotes-deck.rs @@ -34,9 +34,12 @@ scenario options: --scenario free|mission|battle|arena|challenge --scenario-musi chart-stats measures every chart on the whole-live simulation (ournotes-deck.chart-stats/2): the no-skill score and the weight of every score-up kind at every position, with Gekisou on per seed (--seeds N for charts with a luck range, default 8; rank 1, range weights for the other ranks, the Perfect play's scores) and with Gekisou off (offSeeds); and -the chart's aptitude for Gekisou skills: every Gekisou skill shape of its missions alone (at most ---aptitude-max-seeds seeds, default 1024; the cross term on --aptitude-cross-seeds, default 64). ---charts keeps only these score ids; --jobs N measures N charts at once. +the chart's aptitude for Gekisou skills: each skill shape of its missions measured alone, not a best formation. +--aptitude-max-seeds N caps adaptive sampling (default 1024); --aptitude-cross-seeds N caps ordinary-skill cross +terms (default 64). --no-gekisou-aptitude skips aptitude, leaving both aptitude fields null; baseline stats remain. +Increments are seed means with standard errors, not the game's expectation; multiple skill increments cannot be added. +--charts keeps the listed score ids in file order; --jobs N measures N charts at once (default 1). +-o FILE writes JSON to a file; without it JSON goes to stdout. common options: -k N (default 10), --leader ID, --include ID[,ID...], --exclude ID[,ID...], --exclude-snaps ID[,ID...], --no-snaps, --time-limit-ms N"; diff --git a/src/chartstats/aptitude.rs b/src/chartstats/aptitude.rs index 5291a51..a11e4ca 100644 --- a/src/chartstats/aptitude.rs +++ b/src/chartstats/aptitude.rs @@ -67,7 +67,16 @@ pub const MODEL: &str = "charts[].gekisouAptitude: every Gekisou skill shape of increment, 0.1% of the no-skill score); tail = score - sum_j (rangeScore_j + rankBonus_j); at ranks r_j the \ increment is tail + sum_j rangeScore_j * (1 + p_j(r_j) / 100) up to one point per range; weights and \ rangeWeights: the change of the plain kind's weights (cross term) on the first crossSeeds seeds; one check per \ - variant at random ranks and a random plain deck; increments of several shapes do not add up"; + variant at random ranks and a random plain deck at checkPower, failing beyond the flooring bound; increments \ + of several shapes do not add up (combo saturation, luck rush/gauge interactions and Just-count conditions). \ + Baseline seeds and offSeeds are unchanged: no best formation is selected. Only battleLiveScore is affected, \ + never soloScore or Free Live. The seed mean is not the game's expectation; standard error describes sampling \ + variation, not model accuracy. Interpolating the no-live-skill increment between Just and Perfect plays is \ + approximate (13005 conversion, per-Just 2001 and 13002 are nonlinear); Perfect-play cross weights are not \ + measured, so full aptitude with nonzero live skills is unavailable below 100% Just. The Great-rate factor \ + 1 - 0.2q is approximate too. Combo protection \ + 12004, Great-to-Perfect 12006 and judgement-window 4004 have no effect on this best play; 13000, 13002 and \ + 11002 can change range indicators without changing score"; /// Options of the aptitude. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/tests/chartstats.rs b/tests/chartstats.rs index 42f71f2..0bff07c 100644 --- a/tests/chartstats.rs +++ b/tests/chartstats.rs @@ -66,11 +66,20 @@ fn chart_json_fevers(score_id: i64, n: i32, rng: &mut common::Rng, fevers: &[(i3 ) } -/// The synthetic master; with `extra`, also live skill 4 (2000 on the confirmed rank, condition 7012) and 5 (2000 -/// on a Gekisou combo, condition 7005), one level each. -fn document_with(charts: Vec, full_combo: i64, extra: bool) -> Value { +/// The judged notes of a chart (its full combo). +fn judged_of(chart: &Value) -> i64 { + let ops = chart["notes"]["op"].as_array().unwrap(); + ops.iter().filter(|o| ![0, 80, 82, 100, 103, 121, 122, 123].contains(&o.as_i64().unwrap())).count() as i64 +} + +/// The synthetic master and these charts; with `extra`, also live skill 4 (2000 on the confirmed rank, condition +/// 7012) and 5 (2000 on a Gekisou combo, condition 7005), one level each. +fn document_with(charts: Vec, extra: bool) -> Value { let mut rng = common::Rng::new(7); - let mut s = common::synth(&mut rng, 12, 4); + document_from(charts, extra, common::synth(&mut rng, 12, 4)) +} + +fn document_from(charts: Vec, extra: bool, mut s: common::Synth) -> Value { if extra { let mut push = |table: &str, rows: Value| { let t = s.tables.iter_mut().find(|(n, _)| n == table).unwrap(); @@ -98,10 +107,14 @@ fn document_with(charts: Vec, full_combo: i64, extra: bool) -> Value { for (name, rows) in &s.tables { master.insert(name.clone(), columns(rows)); } - master.insert( - "MasterLiveMusicScore".into(), - columns(&json!([{"_id": 1004, "_musicScoreTextFileName": "x", "_musicScoreLevel": 24, "_fullComboCount": full_combo}])), - ); + let scores: Vec = charts + .iter() + .map(|c| { + json!({"_id": c["scoreId"], "_musicScoreTextFileName": "x", "_musicScoreLevel": 24, + "_fullComboCount": judged_of(c)}) + }) + .collect(); + master.insert("MasterLiveMusicScore".into(), columns(&Value::Array(scores))); master.insert( "MasterLiveComboScoreBonus".into(), columns(&json!([{"_id": 1, "_comboBonusType": 0, "_requiredComboCount": 10, "_bonusFactor": 0.01}, @@ -176,9 +189,7 @@ fn data_fevers(n: i32, fevers: &[(i32, i32)]) -> (DeckData, i32) { fn data_with(n: i32, fevers: &[(i32, i32)], extra: bool) -> (DeckData, i32) { let mut rng = common::Rng::new(11); let (chart, last) = chart_json_fevers(1004, n, &mut rng, fevers); - let ops = chart["notes"]["op"].as_array().unwrap(); - let judged = ops.iter().filter(|o| ![0, 80, 82, 100, 103, 121, 122, 123].contains(&o.as_i64().unwrap())).count(); - (DeckData::from_json(&document_with(vec![chart], judged as i64, extra).to_string()).unwrap(), last) + (DeckData::from_json(&document_with(vec![chart], extra).to_string()).unwrap(), last) } const FEVERS: [(i32, i32); 3] = [(8000, 16000), (24000, 32000), (42000, 50000)]; @@ -238,6 +249,10 @@ fn measured_weights_predict_whole_live_simulations() { if i != 1 { assert_eq!(r.lot_results, [0; 4]); } + // the luck points (the luck mission's rank figure) come from the lottery alone without skills: 5 per + // Hit, 10 per Super Hit or Critical + let [_, hit, super_hit, critical] = r.lot_results; + assert_eq!(r.luck_points, 5 * hit + 10 * (super_hit + critical), "range {i}"); } assert_eq!((seed.ranges[0].just_count, seed.ranges[1].just_count), (0, 0)); assert_eq!(seed.ranges[2].just_count, s.just_notes); @@ -254,6 +269,7 @@ fn measured_weights_predict_whole_live_simulations() { assert!(seed.weights[0].iter().all(|&w| w > 0.0), "{:?}", seed.weights[0]); } assert!(s.seeds[0].ranges[1].lot_results.iter().sum::() > 0); + assert!(s.seeds.iter().all(|x| x.ranges[1].luck_points > 0 && x.ranges[0].luck_points == 0)); // real decks: the master's live skills 1 (2000) and 2 (2004) at their levels, simulated directly let chart = d.chart(1004).unwrap(); @@ -337,6 +353,26 @@ fn a_chart_without_luck_ranges_has_one_seed() { } } +/// A combo range alone: nothing is drawn, so one seed; no Just-count range, so the Perfect play is the play; no luck +/// range, so no luck point and no lottery result. +#[test] +fn a_combo_range_alone_has_one_seed_without_just_or_luck() { + let (d, _) = data_fevers(400, &FEVERS[..1]); + let kinds = chartstats::kinds(&d.master); + let s = chartstats::chart_stats(&d.master, &d.charts[0], &kinds, 8).unwrap(); + assert_eq!((s.ranges.len(), s.ranges[0].mission, s.just_notes), (1, 1, 0)); + assert_eq!(s.seeds.iter().map(|x| x.seed).collect::>(), [0]); + let seed = &s.seeds[0]; + assert_eq!(seed.score_perfect, seed.score); + let r = &seed.ranges[0]; + assert!(r.range_score > 0 && r.max_combo > 0); + assert_eq!((r.range_score_perfect, r.just_count), (r.range_score, 0)); + assert_eq!((r.luck_points, r.lot_results), (0, [0; 4])); + assert!((seed.check.exact as f64 - seed.check.predicted).abs() <= seed.check.bound); + let rc = seed.rank_check.as_ref().expect("a rank check"); + assert!((rc.exact as f64 - rc.predicted).abs() <= rc.bound, "{rc:?}"); +} + #[test] fn more_than_three_fevers_cannot_be_played() { let fevers = [(8000, 10000), (20000, 22000), (30000, 32000), (40000, 42000)]; @@ -377,7 +413,7 @@ fn skip_coefficient_bounds_the_skip_score() { fn document_lists_kinds_and_charts() { let (d, _) = data_fevers(80, &FEVERS); let v = chartstats::document(&d, Some(2)).unwrap(); - assert_eq!(v["format"], "ournotes-deck.chart-stats/2"); + assert_eq!(v["format"], chartstats::FORMAT); assert_eq!(v["source"]["region"], "test"); assert_eq!(v["model"]["power"], POWER); assert_eq!(v["kinds"].as_array().unwrap().len(), 4); @@ -396,6 +432,8 @@ fn document_lists_kinds_and_charts() { let rw = seed["rangeWeights"].as_array().unwrap(); assert_eq!((rw.len(), rw[0].as_array().unwrap().len(), rw[0][0].as_array().unwrap().len()), (4, 5, 3)); assert_eq!(seed["rankCheck"]["ranks"].as_array().unwrap().len(), 3); + // the luck points of every range, with or without a luck mission + assert!(seed["ranges"].as_array().unwrap().iter().all(|r| r["luckPoints"].is_i64())); let off = c["offSeeds"].as_array().unwrap(); assert_eq!(off.len(), 1); assert_eq!(off[0].as_object().unwrap().keys().collect::>(), ["check", "score", "seed", "weights"]); @@ -681,3 +719,557 @@ fn kinds_on_the_rank_or_the_gekisou_state() { assert!(off.check.deck.iter().flatten().all(|&(ki, _)| ki != 5)); assert!((off.check.exact as f64 - off.check.predicted).abs() <= off.check.bound); } + +/// The command line: `--charts` keeps these score ids, `--jobs N` measures N charts at once and writes the same +/// document as one at a time, the library's; `-o` writes it to a file. +#[test] +fn the_command_line_measures_the_charts_it_keeps() { + let mut rng = common::Rng::new(11); + let charts = vec![ + chart_json_fevers(1002, 120, &mut rng, &[]).0, + chart_json_fevers(1003, 120, &mut rng, &FEVERS[..1]).0, + chart_json_fevers(1004, 120, &mut rng, &FEVERS).0, + ]; + let text = document_with(charts, false).to_string(); + let dir = std::env::temp_dir().join(format!("chart-stats-cli-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("data.json"); + std::fs::write(&file, &text).unwrap(); + let run = |extra: &[&str]| { + std::process::Command::new(env!("CARGO_BIN_EXE_ournotes-deck")) + .arg("chart-stats") + .arg("--data") + .arg(&file) + .args(extra) + .output() + .unwrap() + }; + let doc = |extra: &[&str]| -> Value { + let o = run(extra); + assert!(o.status.success(), "{extra:?}: {}", String::from_utf8_lossy(&o.stderr)); + serde_json::from_slice(&o.stdout).unwrap() + }; + let one = doc(&["--seeds", "2"]); + assert_eq!(one["format"], chartstats::FORMAT); + let ids: Vec = one["charts"].as_array().unwrap().iter().map(|c| c["scoreId"].as_i64().unwrap()).collect(); + assert_eq!(ids, [1002, 1003, 1004]); + let data = DeckData::from_json(&text).unwrap(); + let options = chartstats::Options { seeds: 2, ..chartstats::Options::default() }; + assert_eq!(one, chartstats::document_with(&data, &options).unwrap()); + assert_eq!(doc(&["--seeds", "2", "--jobs", "2"]), one); + // --charts keeps the listed charts in the file's order; unknown ids keep nothing + let kept = doc(&["--seeds", "2", "--charts", "1004,1002,999", "--jobs", "3"]); + assert_eq!(kept["charts"].as_array().unwrap(), &[one["charts"][0].clone(), one["charts"][2].clone()]); + assert!(doc(&["--charts", "999"])["charts"].as_array().unwrap().is_empty()); + // -o writes the document and prints nothing + let out = dir.join("out.json"); + let o = run(&["--seeds", "2", "--charts", "1003", "-o", out.to_str().unwrap()]); + assert!(o.status.success() && o.stdout.is_empty(), "{}", String::from_utf8_lossy(&o.stderr)); + let written: Value = serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap(); + assert_eq!(written["charts"].as_array().unwrap(), &[one["charts"][1].clone()]); + // bad values fail + for bad in [&["--seeds", "0"][..], &["--jobs", "x"], &["--charts", "a,b"], &["--x"]] { + let o = run(bad); + assert!(!o.status.success() && o.stdout.is_empty(), "{bad:?}"); + } + std::fs::remove_dir_all(&dir).ok(); +} +// Aptitude fixtures: short charts and synthetic skills, levels and bands only. +const APT_FEVERS: [(i32, i32); 3] = [(1000, 1800), (2800, 3600), (4600, 5400)]; + +fn aptitude_json(fevers: &[(i32, i32)]) -> Value { + let mut s = common::synth(&mut common::Rng::new(7), 12, 6); + common::extend_table( + &mut s, + "MasterSkillTarget", + vec![ + json!({"_id":55,"_skillTargetType":5,"_gekisouMissionType":1}), + json!({"_id":56,"_skillTargetType":5,"_gekisouMissionType":2}), + json!({"_id":57,"_skillTargetType":5,"_gekisouMissionType":3}), + json!({"_id":58,"_skillTargetType":3,"_bandID":1}), + json!({"_id":59,"_skillTargetType":3,"_bandID":2}), + json!({"_id":60,"_skillTargetType":4,"_judgement":4}), + ], + ); + common::extend_table( + &mut s, + "MasterSkillCondition", + (0..5) + .map(|i| { + json!({ + "_id":80+i,"_conditionType":if i<3 {7010} else {5000},"_conditionValues":[], + "_isPositive":true,"_conditionTargetIDs":[55+i] + }) + }) + .collect(), + ); + common::extend_table( + &mut s, + "MasterSkillConditionSet", + (0..5) + .map(|i| { + json!({ + "_id":250+i,"_group":250+i,"_conditionIds":[80+i] + }) + }) + .collect(), + ); + // 1 and 2 have identical highest-level effects, but different level numbers and lower-level effects. + let skills = + [(1, 1, 3, 12000, 8), (2, 1, 5, 12000, 8), (3, 2, 2, 11001, 30000), (4, 3, 2, 13000, 2), (5, 2, 2, 11002, 17)]; + common::replace_table( + &mut s, + "MasterGekisouSkill", + skills.iter().map(|&(id, m, _, _, _)| json!({"_id":id,"_gekisouMissionType":m})).collect(), + ); + let mut rows = Vec::new(); + for &(id, mission, level, ty, value) in &skills { + for (lv, v) in [(1, 1), (level, value)] { + rows.push(json!({"_id":rows.len()+1,"_gekisouSkillID":id,"_level":lv, + "_skillTriggerType":1,"_skillTriggerConditionGroup":249+mission, + "_skillEffectType":ty,"_activationTimeSecond":1.2,"_effectValue":v})); + } + } + common::replace_table(&mut s, "MasterGekisouSkillEffect", Value::Array(rows)); + common::set_column(&mut s, "MasterMemberCard", &mut |r| { + r["_gekisouSkillID"] = json!(1 + (r["_id"].as_i64().unwrap() - 1) % 5); + }); + // Support 1 and 2 differ only in band. Rank 5 chooses level 3, NOT the highest effect level 5. + common::replace_table( + &mut s, + "MasterGekisouSupportSkill", + (1..=5).map(|id| json!({"_id":id,"_gekisouMissionType":if id==5 {3} else {1}})).collect(), + ); + let mut rows = Vec::new(); + for id in 1..=5 { + for level in [1, 3, 5] { + rows.push(json!({"_id":rows.len()+1,"_gekisouSupportSkillID":id,"_level":level, + "_skillTriggerType":1,"_skillTriggerConditionGroup":if id==5 {252} else {250}, + "_skillConditionGroup":if id<=2 {252+id} else {0}, + "_skillEffectType":match id {1|2=>2000,3=>12004,4=>12006,_=>4004}, + "_activationTimeSecond":1.2,"_effectValue":if id<=2 {1000*level} else {5}, + "_effectLimitCount":if id==3 || id==4 {3} else {0}, + "_skillTargetIDs":if id==3 || id==4 {vec![60]} else {vec![]}})); + } + } + common::replace_table(&mut s, "MasterGekisouSupportSkillEffect", Value::Array(rows)); + common::set_column(&mut s, "MasterSupportCard", &mut |r| { + r["_gekisouSupportSkillId01"] = json!(1 + (r["_id"].as_i64().unwrap() - 1) % 5); + }); + common::set_column(&mut s, "MasterSupportCardRank", &mut |r| { + r["_gekisouSupportSkill01Level"] = json!(if r["_rank"] == 5 { 3 } else { 1 }); + }); + let chart = json!({"scoreId":1004,"asset":{"key":"synthetic","sha256":"0".repeat(64)}, + "notes":{"id":(1..=64).collect::>(),"op":vec![1;64], + "judgementType":(1..=64).map(|i|if i%4==0 {21} else {1}).collect::>(), + "timeMs":(1..=64).map(|i|i*100).collect::>()}, + "skillEvents":{"timeMs":[900,1200,2700,4500,5400]}, + "fevers":{"startMs":fevers.iter().map(|f|f.0).collect::>(), + "endMs":fevers.iter().map(|f|f.1).collect::>()}}); + document_from(vec![chart], false, s) +} + +fn aptitude_data(fevers: &[(i32, i32)]) -> DeckData { + DeckData::from_json(&aptitude_json(fevers).to_string()).unwrap() +} + +fn aptitude_options(max_seeds: usize, cross_seeds: usize) -> chartstats::Options { + chartstats::Options { seeds: 4, aptitude: Some(chartstats::AptitudeOptions { max_seeds, cross_seeds }) } +} + +fn shape_for(shapes: &[chartstats::Shape], source: &str, skill: i64) -> usize { + shapes.iter().find(|s| s.source == source && s.skills.iter().any(|x| x.id == skill)).unwrap().id +} + +#[test] +fn aptitude_shapes_deduplicate_effects_and_abstract_bands() { + let d = aptitude_data(&APT_FEVERS); + let h = chartstats::aptitude_header(&d.master, &chartstats::kinds(&d.master), &Default::default()); + assert_eq!(h.plain_kind, Some(0)); + assert_eq!(h.seed_rule.deterministic_test, 4); + assert_eq!(h.seed_rule.batches, [32, 64, 128, 256, 512, 1024]); + assert_eq!(h.seed_rule.cross_seeds, 64); + assert_eq!((h.seed_rule.relative, h.seed_rule.baseline), (0.01, 0.001)); + assert_eq!(h.shapes.len(), 8); + assert_eq!(h.shapes.iter().map(|s| s.id).collect::>(), (0..8).collect::>()); + let member = &h.shapes[shape_for(&h.shapes, "member", 1)]; + assert_eq!(member.skills.iter().map(|s| (s.id, s.level)).collect::>(), [(1, 3), (2, 5)]); + assert!(!member.band_condition); + assert!(member.skills.iter().all(|s| s.member_target_ids.is_none() && s.band_ids.is_none())); + let support = &h.shapes[shape_for(&h.shapes, "support", 1)]; + assert_eq!(support.id, shape_for(&h.shapes, "support", 2)); + assert!(support.band_condition); + assert_eq!(support.skills.iter().map(|s| (s.id, s.level)).collect::>(), [(1, 3), (2, 3)]); + for (i, s) in support.skills.iter().enumerate() { + assert_eq!(s.member_target_ids, Some(vec![58 + i as i64])); + assert_eq!(s.band_ids, Some(vec![1 + i as i64])); + } + assert_eq!(support.effects[0].condition[0][0].condition_type, 5000); + assert!(support.effects[0].condition[0][0].target_ids.is_none()); + assert_eq!(support.effects[0].effect_value, 3000); + let mut empty = d; + empty.charts.clear(); + let doc = chartstats::document_with(&empty, &aptitude_options(48, 7)).unwrap(); + assert!(doc["charts"].as_array().unwrap().is_empty()); + assert_eq!(doc["gekisouAptitude"]["seedRule"]["batches"], json!([32, 48])); + assert_eq!(doc["gekisouAptitude"]["shapes"], serde_json::to_value(h.shapes).unwrap()); +} + +#[test] +fn aptitude_variants_gate_missions_measure_bands_and_zero_effects() { + let d = aptitude_data(&APT_FEVERS[..1]); + let shapes = chartstats::shapes(&d.master); + let s = + chartstats::chart_stats_with(&d.master, &d.charts[0], &chartstats::kinds(&d.master), &aptitude_options(32, 4)) + .unwrap(); + let a = s.gekisou_aptitude.as_ref().unwrap(); + assert_eq!(a.variants.len(), 5); + let keys: Vec<_> = a.variants.iter().map(|v| (v.shape, v.band_match)).collect(); + let support = shape_for(&shapes, "support", 1); + assert_eq!( + keys, + vec![ + (shape_for(&shapes, "member", 1), None), + (support, Some(true)), + (support, Some(false)), + (shape_for(&shapes, "support", 3), None), + (shape_for(&shapes, "support", 4), None) + ] + ); + for v in &a.variants { + assert_eq!(shapes[v.shape].mission, 1); + assert!(v.deterministic && v.se_target_met); + assert_eq!((v.seeds, v.cross_seeds, v.check.seed), (1, 1, 0)); + assert_eq!(v.score, v.score_perfect); + assert_eq!(v.tail, v.tail_perfect); + assert_eq!(v.score[1], 0.0); + assert_eq!(v.converted, [0.0, 0.0]); + assert_eq!(v.ranges[0].luck_points, [0.0, 0.0]); + assert!( + (v.score[0] - v.tail[0] - v.ranges.iter().map(|r| r.range_score[0] + r.rank_bonus[0]).sum::()).abs() + < 1e-9 + ); + assert!((v.check.exact as f64 - v.check.predicted).abs() <= v.check.bound, "{v:?}"); + assert!(v.check.ranks.iter().all(|r| (1..=5).contains(r))); + assert_eq!(v.weights.as_ref().unwrap().len(), 5); + assert_eq!(v.range_weights.as_ref().unwrap().len(), 5); + if v.band_match == Some(false) || [12004, 12006].contains(&shapes[v.shape].effects[0].effect_type) { + assert_eq!(v.score, [0.0, 0.0]); + assert_eq!(v.tail, [0.0, 0.0]); + assert!(v.weights.as_ref().unwrap().iter().all(|w| *w == [0.0, 0.0])); + } + } + assert!(a.variants.iter().find(|v| v.shape == support && v.band_match == Some(true)).unwrap().score[0] > 0.0); + assert_eq!((a.factors[0].just_notes, a.factors[0].perfect_notes, a.factors[0].lotteries), (0, 0, [0.0, 0.0])); +} + +/// Direct engine run, assembling a performer independently of the aptitude implementation. +fn aptitude_run( + d: &DeckData, + performer: Option, + seed: i32, + perfect: bool, +) -> (i32, Vec) { + let chart = d.chart(1004).unwrap(); + let setup = GekisouSetup { fevers: d.charts[0].fevers.clone(), missions: vec![1, 2, 3] }; + let rule = JustRule::new(&d.master, &setup).unwrap(); + let mut stream = JudgementStream::theoretical_best_gekisou(&chart, &d.charts[0].judgement_types, &rule).unwrap(); + if perfect { + for j in &mut stream.judged { + if j[2] == 6 { + j[2] = 5; + } + } + } + let dt = stream.delta_times().unwrap(); + let mut play = stream.to_live_play().unwrap(); + play.base_seed = seed; + let params = LiveParams { + total_power: POWER, + music_level: 24, + converted_note_count: chart.converted_note_count, + music_length_ms: chart.last_timing_note_ms + 1000, + score_music_length_ms: None, + assist_factor: 1.0, + skill_target_music_type: 1, + }; + let mut deck: Vec<_> = performer.into_iter().collect(); + deck.resize(5, Performer::default()); + let events: Vec<_> = chart.skill_events.iter().map(|e| (e.index, e.time_ms)).collect(); + let mut model = full::LiveModel::new_gekisou(&d.master, &deck, ¬es_of(d), &events, params, &setup).unwrap(); + let score = model.run_timed(&play, &dt).unwrap(); + (score, model.gekisou_ranges()) +} + +#[test] +fn aptitude_support_uses_an_empty_host_and_exact_tail() { + let mut d = aptitude_data(&APT_FEVERS[..1]); + let kinds = chartstats::kinds(&d.master); + let s = chartstats::chart_stats_with(&d.master, &d.charts[0], &kinds, &aptitude_options(32, 4)).unwrap(); + let shape = shape_for(&chartstats::shapes(&d.master), "support", 1); + let v = s + .gekisou_aptitude + .as_ref() + .unwrap() + .variants + .iter() + .find(|v| v.shape == shape && v.band_match == Some(true)) + .unwrap(); + // A different synthetic id: the host's identity is irrelevant; its lack of effects is not. + d.master.gekisou_skills.push(ournotes_deck::master::SkillRow { + id: -999, + gekisou_mission_type: 1, + ..Default::default() + }); + d.master.reindex().unwrap(); + let host = Performer { + band_id: 1, + gekisou_skill: Some((-999, 1)), + gekisou_support_skills: vec![(1, 3)], + ..Default::default() + }; + let (base, br) = aptitude_run(&d, None, 0, false); + let (with, wr) = aptitude_run(&d, Some(host.clone()), 0, false); + let rs = |r: &full::GekisouRange| r.end_score - r.start_score; + assert_eq!(v.score, [f64::from(with - base), 0.0]); + assert_eq!(v.ranges[0].range_score, [f64::from(rs(&wr[0]) - rs(&br[0])), 0.0]); + assert_eq!(v.tail[0], f64::from(with - base) - v.ranges[0].range_score[0] - v.ranges[0].rank_bonus[0]); + assert!(v.tail[0] > 0.0, "skill lasts beyond the range end"); + let (without_host, _) = aptitude_run(&d, Some(Performer { gekisou_skill: None, ..host.clone() }), 0, false); + assert_eq!(without_host, base, "support is gated off without a member skill"); + let (real_host, _) = aptitude_run(&d, Some(Performer { gekisou_skill: Some((1, 3)), ..host }), 0, false); + assert_ne!(real_host, with, "borrowing a real card contaminates the single-skill measurement"); +} + +fn test_mean_se(values: &[f64]) -> [f64; 2] { + let n = values.len() as f64; + let mean = values.iter().sum::() / n; + [ + mean, + if n == 1.0 { 0.0 } else { (values.iter().map(|v| (v - mean).powi(2)).sum::() / (n * (n - 1.0))).sqrt() }, + ] +} + +#[test] +fn aptitude_seed_statistics_match_paired_full_runs() { + let d = aptitude_data(&APT_FEVERS); + let shapes = chartstats::shapes(&d.master); + let options = aptitude_options(64, 4); + let s = chartstats::chart_stats_with(&d.master, &d.charts[0], &[], &options).unwrap(); + let a = s.gekisou_aptitude.as_ref().unwrap(); + let v = a.variants.iter().find(|v| v.shape == shape_for(&shapes, "member", 3)).unwrap(); + assert!(!v.deterministic); + assert!([32, 64].contains(&v.seeds)); + assert!(v.weights.is_none() && v.range_weights.is_none()); + assert!(v.check.deck.iter().all(Option::is_none)); + let mut scores = Vec::new(); + let mut bases = Vec::new(); + let mut tails = Vec::new(); + for seed in ournotes_deck::live::seeds::published_seeds(v.seeds) { + let (base, br) = aptitude_run(&d, None, seed, false); + let (with, wr) = + aptitude_run(&d, Some(Performer { gekisou_skill: Some((3, 2)), ..Default::default() }), seed, false); + scores.push(f64::from(with - base)); + bases.push(f64::from(base)); + let range_delta: i32 = wr + .iter() + .zip(br) + .map(|(x, y)| { + x.end_score - x.start_score - y.end_score + y.start_score + x.rank_bonus.unwrap_or(0) + - y.rank_bonus.unwrap_or(0) + }) + .sum(); + tails.push(f64::from(with - base - range_delta)); + } + for (reported, actual) in [(v.score, test_mean_se(&scores)), (v.tail, test_mean_se(&tails))] { + for i in 0..2 { + assert!((reported[i] - actual[i]).abs() <= 0.000501, "{reported:?} {actual:?}"); + } + } + let met = |scores: &[f64], bases: &[f64]| { + let [m, se] = test_mean_se(scores); + se <= (m.abs() * 0.01).max(test_mean_se(bases)[0] * 0.001) + }; + assert_eq!(v.se_target_met, met(&scores, &bases)); + if v.seeds == 64 { + assert!(!met(&scores[..32], &bases[..32])); + } + assert!( + (v.score[0] - v.tail[0] - v.ranges.iter().map(|r| r.range_score[0] + r.rank_bonus[0]).sum::()).abs() + < 0.004 + ); + for v in &a.variants { + assert!((v.check.exact as f64 - v.check.predicted).abs() <= v.check.bound); + if [13000, 11002, 4004].contains(&shapes[v.shape].effects[0].effect_type) { + assert_eq!(v.score, [0.0, 0.0]); + } + if v.deterministic { + assert_eq!((v.seeds, v.se_target_met, v.score[1]), (1, true, 0.0)); + } + } + let just = a.variants.iter().find(|v| v.shape == shape_for(&shapes, "member", 4)).unwrap(); + assert!(just.ranges[2].just_count[0] > 0.0); + let points = a.variants.iter().find(|v| v.shape == shape_for(&shapes, "member", 5)).unwrap(); + assert_eq!(points.ranges[1].luck_points, [17.0, 0.0]); + // Factors' lottery moments use the baseline seeds, not the adaptive variant seed count. + let lots: Vec<_> = s.seeds.iter().map(|s| s.ranges[1].lot_results.iter().sum::() as f64).collect(); + assert_eq!(a.factors[1].lotteries, test_mean_se(&lots)); + assert_eq!(a.factors[2].judged_notes, a.factors[2].just_notes + a.factors[2].perfect_notes); + assert!(a.factors[2].just_notes > 0 && a.factors[2].perfect_notes > 0); +} + +fn assert_keys(v: &Value, keys: &[&str]) { + let actual: std::collections::BTreeSet<_> = v.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!(actual, keys.iter().copied().collect()); +} + +#[test] +fn aptitude_document_serialization_and_null_cases() { + let d = aptitude_data(&APT_FEVERS[..1]); + let options = aptitude_options(32, 4); + let v = chartstats::document_with(&d, &options).unwrap(); + assert_eq!(v["format"], "ournotes-deck.chart-stats/2"); + assert!(v["model"]["gekisouAptitude"].as_str().is_some_and(|s| !s.contains(" "))); + let h = &v["gekisouAptitude"]; + assert_keys(h, &["plainKind", "host", "seedRule", "shapes"]); + assert_keys(&h["seedRule"], &["deterministicTest", "batches", "relative", "baseline", "crossSeeds"]); + let shape = &h["shapes"][0]; + assert_keys(shape, &["id", "source", "mission", "bandCondition", "effects", "skills"]); + assert_keys(&shape["skills"][0], &["id", "level", "memberTargetIds", "bandIds"]); + assert_keys( + &shape["effects"][0], + &[ + "effectType", + "triggerType", + "activationTimeSecond", + "effectValue", + "maxEffectValue", + "effectLimitCount", + "effectExecuteLimitCount", + "skillTargetIds", + "trigger", + "condition", + "release", + "reset", + "cumulative", + ], + ); + assert_keys(&shape["effects"][0]["trigger"][0][0], &["type", "values", "positive", "targetIds"]); + let a = &v["charts"][0]["gekisouAptitude"]; + assert_keys(a, &["factors", "variants"]); + assert_keys( + &a["factors"][0], + &["judgedNotes", "justNotes", "perfectNotes", "tailNotes", "comboAtStart", "lotteries"], + ); + let x = &a["variants"][0]; + assert_keys( + x, + &[ + "shape", + "bandMatch", + "deterministic", + "seeds", + "seTargetMet", + "crossSeeds", + "score", + "scorePerfect", + "tail", + "tailPerfect", + "converted", + "ranges", + "weights", + "rangeWeights", + "check", + ], + ); + assert_keys( + &x["ranges"][0], + &["rangeScore", "rankBonus", "rangeScorePerfect", "maxCombo", "justCount", "luckPoints"], + ); + assert_keys(&x["check"], &["seed", "ranks", "deck", "exact", "predicted", "bound"]); + for key in ["score", "scorePerfect", "tail", "tailPerfect", "converted"] { + assert_eq!(x[key].as_array().unwrap().len(), 2); + } + assert!(v["charts"][0].get("gekisou").is_none()); + let disabled = chartstats::document_with(&d, &chartstats::Options { aptitude: None, ..options }).unwrap(); + assert_eq!(disabled.get("gekisouAptitude"), Some(&Value::Null)); + assert_eq!(disabled["charts"][0].get("gekisouAptitude"), Some(&Value::Null)); + assert_eq!(v["charts"][0]["seeds"], disabled["charts"][0]["seeds"]); + assert_eq!(v["charts"][0]["offSeeds"], disabled["charts"][0]["offSeeds"]); + for fevers in [vec![], vec![(1000, 1800), (2800, 3600), (4600, 5400), (5800, 6000)]] { + let data = aptitude_data(&fevers); + let result = chartstats::document_with(&data, &options).unwrap(); + assert_eq!(result["charts"][0].get("gekisouAptitude"), Some(&Value::Null)); + } + let (plain, _) = data_fevers(40, &FEVERS); + let no_skills = chartstats::document_with(&plain, &options).unwrap(); + assert_eq!(no_skills["charts"][0].get("gekisouAptitude"), Some(&Value::Null)); +} + +#[test] +fn aptitude_rare_probability_is_not_proven_deterministic_by_four_equal_samples() { + let mut d = aptitude_data(&APT_FEVERS[..1]); + // A 1% score-up on range start: the first four published seeds happen to miss it. + d.master.skill_conditions.push( + serde_json::from_value(json!({"_id":900,"_conditionType":4011, + "_conditionValues":[1],"_conditionTargetIDs":[],"_isPositive":true})) + .unwrap(), + ); + d.master + .skill_condition_sets + .push(serde_json::from_value(json!({"_id":900,"_group":900,"_conditionIds":[80,900]})).unwrap()); + d.master.gekisou_skill_effects.retain(|r| r.skill_id == 1); + d.master.gekisou_support_skill_effects.clear(); + for r in &mut d.master.gekisou_skill_effects { + r.skill_trigger_condition_group = 900; + r.skill_effect_type = 2000; + r.effect_value = 10000; + } + d.master.reindex().unwrap(); + for seed in ournotes_deck::live::seeds::published_seeds(4) { + let (base, _) = aptitude_run(&d, None, seed, false); + let (with, _) = + aptitude_run(&d, Some(Performer { gekisou_skill: Some((1, 3)), ..Default::default() }), seed, false); + assert_eq!(with, base, "fixture must have four equal zero increments"); + } + let s = chartstats::chart_stats_with(&d.master, &d.charts[0], &[], &aptitude_options(32, 4)).unwrap(); + let v = &s.gekisou_aptitude.as_ref().unwrap().variants[0]; + assert!(!v.deterministic, "four equal samples do not prove a probability condition deterministic"); + assert_eq!(v.seeds, 32); +} + +#[test] +fn aptitude_cli_flags_disable_and_bound_measurements() { + let dir = std::env::temp_dir().join(format!("aptitude-cli-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("data.json"); + std::fs::write(&path, aptitude_json(&APT_FEVERS).to_string()).unwrap(); + let run = |extra: &[&str]| { + std::process::Command::new(env!("CARGO_BIN_EXE_ournotes-deck")) + .args(["chart-stats", "--data"]) + .arg(&path) + .args(extra) + .output() + .unwrap() + }; + let o = run(&["--no-gekisou-aptitude"]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let off: Value = serde_json::from_slice(&o.stdout).unwrap(); + assert_eq!(off.get("gekisouAptitude"), Some(&Value::Null)); + assert_eq!(off["charts"][0].get("gekisouAptitude"), Some(&Value::Null)); + let o = run(&["--aptitude-max-seeds", "32", "--aptitude-cross-seeds", "2"]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let v: Value = serde_json::from_slice(&o.stdout).unwrap(); + assert_eq!(v["gekisouAptitude"]["seedRule"]["batches"], json!([32])); + assert_eq!(v["gekisouAptitude"]["seedRule"]["crossSeeds"], 2); + for x in v["charts"][0]["gekisouAptitude"]["variants"].as_array().unwrap() { + let seeds = x["seeds"].as_u64().unwrap(); + assert!(seeds == 1 || seeds == 32); + assert_eq!(x["crossSeeds"].as_u64().unwrap(), seeds.min(2)); + assert!(x["check"]["predicted"].is_number()); + } + for args in [["--aptitude-max-seeds", "0"], ["--aptitude-max-seeds", "x"], ["--aptitude-cross-seeds", "x"]] { + assert!(!run(&args).status.success()); + } + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index bf64157..9a4188a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -393,7 +393,7 @@ pub fn play(rng: &mut Rng, chart: &Chart) -> ournotes_deck::live::model::Play { } /// Sets columns of every row of table `name`. -fn set_column(s: &mut Synth, name: &str, f: &mut dyn FnMut(&mut Value)) { +pub fn set_column(s: &mut Synth, name: &str, f: &mut dyn FnMut(&mut Value)) { for (n, v) in s.tables.iter_mut() { if n == name { for r in v.as_array_mut().unwrap() { @@ -403,12 +403,12 @@ fn set_column(s: &mut Synth, name: &str, f: &mut dyn FnMut(&mut Value)) { } } -fn replace_table(s: &mut Synth, name: &str, v: Value) { +pub fn replace_table(s: &mut Synth, name: &str, v: Value) { s.tables.retain(|(n, _)| n != name); s.tables.push((name.to_string(), v)); } -fn extend_table(s: &mut Synth, name: &str, rows: Vec) { +pub fn extend_table(s: &mut Synth, name: &str, rows: Vec) { for (n, v) in s.tables.iter_mut() { if n == name { v.as_array_mut().unwrap().extend(rows); From 7fbc9d775e77191fcb44adc05bef5cade8f44ff7 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 30 Sep 2026 04:44:20 +0800 Subject: [PATCH 09/11] docs: avoid unverified Just-count interaction claim Co-Authored-By: Claude Code --- src/chartstats/aptitude.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chartstats/aptitude.rs b/src/chartstats/aptitude.rs index c9fb7e3..3437a55 100644 --- a/src/chartstats/aptitude.rs +++ b/src/chartstats/aptitude.rs @@ -19,7 +19,7 @@ //! the first `cross_seeds` of them. //! //! The increments of several shapes do not add up: the Gekisou combo factor saturates, the luck rush support skills -//! and the luck gauge skills multiply, and Just count additions reach the support skills triggered per Just count. +//! and the luck gauge skills interact. Judgement conversion can also change the input to other skills. use serde::Serialize; From 5ee7f65f5cad52dfe36a3d3d244c698a87a74b0b Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 30 Sep 2026 04:45:12 +0800 Subject: [PATCH 10/11] test: guard aptitude sampling limits and nonfinite check results Cover both library entry points, CLI zero cross seeds, missing plain-kind cross terms and cumulative Just indicators. Clarify structural determinism and sampling limits. Co-Authored-By: Claude Code --- README.en.md | 8 +++-- README.md | 6 ++-- src/chartstats.rs | 24 +++++++++++++- src/chartstats/aptitude.rs | 5 +-- tests/chartstats.rs | 67 +++++++++++++++++++++++++++++++++++++- 5 files changed, 100 insertions(+), 10 deletions(-) diff --git a/README.en.md b/README.en.md index 7b3fe59..e012ed7 100644 --- a/README.en.md +++ b/README.en.md @@ -181,8 +181,8 @@ ournotes-deck chart-stats --data deck-data.json --aptitude-max-seeds 128 --aptit ournotes-deck chart-stats --data deck-data.json --no-gekisou-aptitude -o baseline.json ``` -- `--aptitude-max-seeds N`: at most N seeds for a random increment, default 1024; stop earlier at the SE target. -- `--aptitude-cross-seeds N`: at most the first N seeds for ordinary skill cross terms, default 64. +- `--aptitude-max-seeds N`: at most N seeds for a random increment, default 1024, N at least 2; stop earlier at the SE target. +- `--aptitude-cross-seeds N`: at most the first N seeds for ordinary skill cross terms, default 64, N at least 1. - `--no-gekisou-aptitude`: skip aptitude measurement; both file-level and per-chart `gekisouAptitude` are null. Existing statistics are still produced. @@ -205,7 +205,9 @@ Random increments use batches of 32, 64, 128, 256, 512 and 1024 seeds, capped by most `max(1% × |mean increment|, 0.1% × mean no-skill score)`. At the cap, `seTargetMet` reports whether this target was met. Deterministic increments report one seed and zero SE; four identical samples alone cannot establish that a random skill is deterministic. The seed mean is not the game's expectation: its seed law is unknown, and SE -does not measure model error. Cross terms can use fewer seeds, reported as `crossSeeds` per variant. +does not measure model error. Cross terms can use fewer seeds, reported as `crossSeeds` per variant (0 without a +plain kind). Meeting the SE target may only satisfy the absolute baseline threshold, not 1% relative precision +on the increment; a small sample mean's sign alone does not establish that a skill helps or hurts. **Model limits:** diff --git a/README.md b/README.md index e25a9c4..4f51b78 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,8 @@ ournotes-deck chart-stats --data deck-data.json --aptitude-max-seeds 128 --aptit ournotes-deck chart-stats --data deck-data.json --no-gekisou-aptitude -o baseline.json ``` -- `--aptitude-max-seeds N`:随机增量最多测 N 个种子,默认 1024;标准误足够小时提前停止。 -- `--aptitude-cross-seeds N`:普通技能交叉项最多测前 N 个种子,默认 64。 +- `--aptitude-max-seeds N`:随机增量最多测 N 个种子,默认 1024,N 至少为 2;标准误足够小时提前停止。 +- `--aptitude-cross-seeds N`:普通技能交叉项最多测前 N 个种子,默认 64,N 至少为 1。 - `--no-gekisou-aptitude`:跳过适性测量,文件级和每谱的 `gekisouAptitude` 都为 null,原有统计照常输出。 形状按来源、任务与效果参数去重。成员技能取该技能的最高等级;小卡技能取最高突破对应的等级,不直接取效果表最高等级。 @@ -165,7 +165,7 @@ ournotes-deck chart-stats --data deck-data.json --no-gekisou-aptitude -o baselin 随机增量按 32、64、128、256、512、1024 个种子逐级测量(受最大种子参数限制),当 Δscore 的标准误不超过 `max(增量均值绝对值 × 1%, 无技能总分均值 × 0.1%)` 时停止;到上限仍不满足则 `seTargetMet: false`。确定性增量报一个 种子、标准误 0;四个种子恰好相等本身不能证明随机技能是确定性的。种子均值不是游戏的期望,真实种子分布未知,标准误也 -不表示模型误差。交叉项可能使用更少种子,见各变体的 `crossSeeds`。 +不表示模型误差。交叉项可能使用更少种子,见各变体的 `crossSeeds`;无普通 kind 时该值为 0。达到标准误目标可能只是满足基线 0.1%的绝对目标,不代表达到增量 1%的相对精度,也不能据小增量均值的正负断言技能一定有益或有害。 **模型边界:** diff --git a/src/chartstats.rs b/src/chartstats.rs index 8660d3d..1066bdb 100644 --- a/src/chartstats.rs +++ b/src/chartstats.rs @@ -1061,7 +1061,29 @@ pub fn document_with(data: &DeckData, options: &Options) -> Result= 0.0); + } +} + +#[test] +fn aptitude_cumulative_just_bonus_changes_indicators_not_score() { + let mut d = aptitude_data(&APT_FEVERS); + d.master.gekisou_skill_effects.retain(|r| r.skill_id == 4); + d.master.gekisou_support_skill_effects.clear(); + d.master.cumulative_conditions.push( + serde_json::from_value(json!({"_id":900, + "_skillCumulativeConditionType":7000,"_conditionValues":[2],"_conditionTargetIDs":[], + "_maxCumulativeCount":100})) + .unwrap(), + ); + for r in &mut d.master.gekisou_skill_effects { + r.skill_effect_type = 13002; + r.skill_cumulative_condition_id = 900; + r.effect_value = 1; + r.max_effect_value = 100; + } + d.master.reindex().unwrap(); + let h = chartstats::aptitude_header(&d.master, &[], &Default::default()); + let cumulative = serde_json::to_value(&h.shapes[0].effects[0].cumulative).unwrap(); + assert_keys(&cumulative, &["type", "values", "targetIds", "maxCumulativeCount"]); + assert_eq!(cumulative["values"], json!([2])); + let s = chartstats::chart_stats_with(&d.master, &d.charts[0], &[], &aptitude_options(32, 4)).unwrap(); + let v = &s.gekisou_aptitude.as_ref().unwrap().variants[0]; + assert_eq!(v.score, [0.0, 0.0]); + assert_eq!(v.score_perfect, [0.0, 0.0]); + assert!(v.ranges[2].just_count[0] > 0.0); +} From dbd9cf01a4854808dceb6aedcd4041af372faeee Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 30 Sep 2026 13:19:30 +0800 Subject: [PATCH 11/11] fix: align native scoring and add the shared replay runtime Correct scoring gates, pool timing, rank snapshots and Just-window wiring against original ARM64 captures. Check both aptitude precision endpoints. Expose the same per-note replay API to Rust and WebAssembly, with public validation summaries and strict resource-independent comparison tools. Co-Authored-By: Claude Code --- README.en.md | 23 +- README.md | 18 +- docs/native-validation.en.md | 52 ++ docs/native-validation.md | 52 ++ docs/validation/native-cards-2026-09-30.json | 867 ++++++++++++++++++ .../native-coverage-2026-09-30.json | 567 ++++++++++++ .../native-longcharts-2026-09-30.json | 559 +++++++++++ docs/validation/native-modes-2026-09-30.json | 333 +++++++ docs/validation/native-source-2026-09-30.json | 61 ++ docs/validation/native-touch-2026-09-30.md | 16 + .../probability-4011-2026-09-30.json | 187 ++++ .../production-checks-2026-09-30.json | 248 +++++ docs/validation/replay-2026-09-30.json | 31 + .../two-length-resolution-2026-09-30.json | 104 +++ src/bin/ournotes-deck.rs | 2 +- src/chartstats.rs | 19 +- src/chartstats/aptitude.rs | 55 +- src/lib.rs | 1 + src/live/full/engine.rs | 31 +- src/live/full/mod.rs | 93 +- src/live/full/raw_runtime.rs | 12 + src/replay.rs | 735 +++++++++++++++ tests/chartstats.rs | 22 +- tests/gekisou.rs | 103 +++ tests/livefull.rs | 38 + tests/raw_bridge.rs | 21 +- tests/raw_just_fever.rs | 71 ++ tests/replay.rs | 218 +++++ tools/native-validation/.gitignore | 2 + tools/native-validation/README.md | 35 + .../compare_native_frames.py | 121 +++ .../native-validation/make_source_manifest.py | 51 ++ .../native-validation/ordinary-contract.json | 6 + .../test_compare_native_frames.py | 58 ++ wasm/replay/Cargo.lock | 195 ++++ wasm/replay/Cargo.toml | 18 + wasm/replay/src/lib.rs | 29 + 37 files changed, 5004 insertions(+), 50 deletions(-) create mode 100644 docs/native-validation.en.md create mode 100644 docs/native-validation.md create mode 100644 docs/validation/native-cards-2026-09-30.json create mode 100644 docs/validation/native-coverage-2026-09-30.json create mode 100644 docs/validation/native-longcharts-2026-09-30.json create mode 100644 docs/validation/native-modes-2026-09-30.json create mode 100644 docs/validation/native-source-2026-09-30.json create mode 100644 docs/validation/native-touch-2026-09-30.md create mode 100644 docs/validation/probability-4011-2026-09-30.json create mode 100644 docs/validation/production-checks-2026-09-30.json create mode 100644 docs/validation/replay-2026-09-30.json create mode 100644 docs/validation/two-length-resolution-2026-09-30.json create mode 100644 src/replay.rs create mode 100644 tests/raw_just_fever.rs create mode 100644 tests/replay.rs create mode 100644 tools/native-validation/.gitignore create mode 100644 tools/native-validation/README.md create mode 100644 tools/native-validation/compare_native_frames.py create mode 100644 tools/native-validation/make_source_manifest.py create mode 100644 tools/native-validation/ordinary-contract.json create mode 100644 tools/native-validation/test_compare_native_frames.py create mode 100644 wasm/replay/Cargo.lock create mode 100644 wasm/replay/Cargo.toml create mode 100644 wasm/replay/src/lib.rs diff --git a/README.en.md b/README.en.md index e012ed7..45a8dcc 100644 --- a/README.en.md +++ b/README.en.md @@ -26,7 +26,7 @@ Deck power, skip score and live score for BanG Dream! Our Notes, and an exact To ## Correctness -Correctness has three layers, and each shows something different. +The checks below establish distinct contracts, each with its own scope and source identity. **Agreement with the game.** Every calculation follows the game client's code function by function. Each unit is then checked against the client's own implementation: the client's arm64 native functions run in an emulator on the @@ -58,9 +58,11 @@ A search that reaches its time limit returns `TimedOut`, with legal and exactly ranking claim. Inputs outside the proven range, unknown cards, rules the game would reject and parts of the game that are not modelled are reported as errors. -**Not yet verified.** How the units combine, frame by frame, into a whole live has not been compared with the game as -a whole. That needs a recording of a play on a device: the random seed, frame times and each note's judgement. -With Gekisou on the score depends on the random seed; the search does not offer a Gekisou objective yet. +**Native update chains.** Offline Unicorn ARM64 executes real client scoring, skill and ranking chains, compared frame by frame at matching inputs and phases. Nine high/medium/low-accuracy × 30/60/120 fps cases have 57,750 frames and 18,826,500 core checks with zero differences. Seven long-chart cases separately have 70,747 frames and 19,971,785 checks with zero differences. Card Gekisou, probability triggers, life conditions and dynamic windows have their own contracts; see the [native validation table](docs/native-validation.en.md). Field counts and reused captures are not additional independent samples. + +**Shared runtime.** Per-note replay uses the same Rust model. Across 340 charts × 2 judgement plans × 2 modes, 1,360 Rust / WASM runs match; see the [runtime identity](docs/validation/replay-2026-09-30.json). The checked scoring-model snapshot passes 272 release tests, clippy with denied warnings, fmt and Rust 1.88 checks; see the [production record](docs/validation/production-checks-2026-09-30.json). + +**Applicability.** Results are limited to declared resources, inputs, fields and phases. A raw Touch sample using private resource adapters does not release complete physical-touch support; full device and server lifecycles require separate evidence. A per-play result uses explicit judgements, frame order, seed and ranking policy; statistical baselines and weights do not replace it. ## Data @@ -181,7 +183,7 @@ ournotes-deck chart-stats --data deck-data.json --aptitude-max-seeds 128 --aptit ournotes-deck chart-stats --data deck-data.json --no-gekisou-aptitude -o baseline.json ``` -- `--aptitude-max-seeds N`: at most N seeds for a random increment, default 1024, N at least 2; stop earlier at the SE target. +- `--aptitude-max-seeds N`: at most N seeds for a random increment, default 65536, N at least 2; stop earlier when both grade endpoints meet their SE targets. - `--aptitude-cross-seeds N`: at most the first N seeds for ordinary skill cross terms, default 64, N at least 1. - `--no-gekisou-aptitude`: skip aptitude measurement; both file-level and per-chart `gekisouAptitude` are null. Existing statistics are still produced. @@ -201,9 +203,11 @@ position; `rangeWeights` gives the corresponding range changes, not full formati a plain kind. Each variant's `check` uses its first measured seed, random ranks and an ordinary-skill deck at another power to validate the linear prediction; exceeding its flooring bound fails the measurement. -Random increments use batches of 32, 64, 128, 256, 512 and 1024 seeds, capped by the option, until Δscore's SE is at -most `max(1% × |mean increment|, 0.1% × mean no-skill score)`. At the cap, `seTargetMet` reports whether this target -was met. Deterministic increments report one seed and zero SE; four identical samples alone cannot establish that +Random increments start at 32 seeds and double along the same seed prefix until the configured cap. Preset batches +extend to 65536; a cap outside those boundaries is included as the final batch. +`score` and `scorePerfect` each use their own increment mean and no-skill baseline. Both SEs must be at most +`max(1% × |mean increment|, 0.1% × mean no-skill score)` before stopping. At the cap, `seTargetMet` reports whether +both targets were met. Deterministic increments report one seed and zero SE; four identical samples alone cannot establish that a random skill is deterministic. The seed mean is not the game's expectation: its seed law is unknown, and SE does not measure model error. Cross terms can use fewer seeds, reported as `crossSeeds` per variant (0 without a plain kind). Meeting the SE target may only satisfy the absolute baseline threshold, not 1% relative precision @@ -223,6 +227,9 @@ on the increment; a small sample mean's sign alone does not establish that a ski cross weights are not measured, so full aptitude with nonzero ordinary skills is unavailable below 100% Just. Scaling by `1 − 0.2q` for Great proportion q is also approximate. +These interpolation and scaling limits concern statistical summaries. A declared per-note play uses the shared +`replay` API with its actual frame order, skills and seed; see the [model contract](docs/native-validation.en.md#shared-model-contract). + Library callers can use `chart_stats_with` / `document_with` with `Options { seeds, aptitude: Some(AptitudeOptions { max_seeds, cross_seeds }) }`; `aptitude: None` disables it. A `DeckData` without charts still produces the shape header, also available through diff --git a/README.md b/README.md index 4f51b78..0c2f806 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ BanG Dream! Our Notes 的综合力、跳过分数与演出分数计算,以及 ## 正确性 -正确性分三层,各层证明的内容不同。 +各项核对约束不同的计算契约,来源与范围分别记录。 **与游戏一致。** 各项计算按游戏客户端的代码逐函数实现,并逐个计算单元与客户端自身的实现对照:在模拟器中以相同输入执行 客户端的 arm64 原生函数,按位比较结果(浮点按位比较,整数溢出与抛出异常的路径也一并比较)。对照覆盖: @@ -42,8 +42,11 @@ BanG Dream! Our Notes 的综合力、跳过分数与演出分数计算,以及 达到时间上限的搜索返回 `TimedOut`,其中的编成合法且数值精确,但不保证排名。 超出已证明范围的输入、未知卡牌、游戏会拒绝的规则以及尚未建模的部分,都以错误返回。 -**尚未验证的部分。** 整场演出如何由各单元按帧拼成,没有与游戏整体对照:这需要一份真机录制的对局(随机种子、帧时间与逐音符 -判定)。撃奏开启时分数取决于随机种子;搜索尚未提供撃奏目标。 +**原生更新链。** 离线 Unicorn ARM64 执行真实客户端计分、技能与排名链,在相同输入与阶段逐帧核对。高、中、低准度 × 30/60/120 fps 九组共 57,750 帧、18,826,500 核心比较零差异;长谱七组另有 70,747 帧、19,971,785 比较零差异。卡上撃奏、概率触发、生命条件及动态窗口按各自契约核对,结果与来源见[原生验证表](docs/native-validation.md)。字段次数与复用捕获不计为新增独立样本。 + +**共享运行时。** 逐音符计算共用 Rust 模型;340 谱 × 2 种判定计划 × 2 模式的 1,360 组 Rust / WASM 结果零差异,见[运行时身份](docs/validation/replay-2026-09-30.json)。计分模型快照通过 272 项 release 回归、clippy 零警告、fmt 和 Rust 1.88 检查,见[生产来源清单](docs/validation/production-checks-2026-09-30.json)。 + +**适用范围。** 核对结论限定于声明资源、输入、字段和阶段。私有资源适配器上的原始 Touch 样本不代表发布了完整物理触控能力;完整设备与服务器生命周期另需证据。逐音符单局结果使用明确判定、帧序、seed 与排名策略;统计基线及权重不替代它。 ## 数据 @@ -148,7 +151,7 @@ ournotes-deck chart-stats --data deck-data.json --aptitude-max-seeds 128 --aptit ournotes-deck chart-stats --data deck-data.json --no-gekisou-aptitude -o baseline.json ``` -- `--aptitude-max-seeds N`:随机增量最多测 N 个种子,默认 1024,N 至少为 2;标准误足够小时提前停止。 +- `--aptitude-max-seeds N`:随机增量最多测 N 个种子,默认 65536,N 至少为 2;两个判定端均达到标准误目标时提前停止。 - `--aptitude-cross-seeds N`:普通技能交叉项最多测前 N 个种子,默认 64,N 至少为 1。 - `--no-gekisou-aptitude`:跳过适性测量,文件级和每谱的 `gekisouAptitude` 都为 null,原有统计照常输出。 @@ -162,8 +165,9 @@ ournotes-deck chart-stats --data deck-data.json --no-gekisou-aptitude -o baselin 的变化,`rangeWeights` 是对应区间权重变化;不是完整编成权重,没有普通 kind 时两者为 null。每个变体的 `check` 用首个 测量种子、随机名次与随机普通技能卡组,在另一综合力下验证线性预测,超出取整界时报错。 -随机增量按 32、64、128、256、512、1024 个种子逐级测量(受最大种子参数限制),当 Δscore 的标准误不超过 -`max(增量均值绝对值 × 1%, 无技能总分均值 × 0.1%)` 时停止;到上限仍不满足则 `seTargetMet: false`。确定性增量报一个 +随机增量从 32 个种子起按同一 seed 前缀倍增,按配置的最大种子数停止;预设批次延伸至 65536,非批次边界的上限另作为最后一批。`score` 与 `scorePerfect` 分别使用 +各自的增量均值和无技能总分基线;两者的标准误均不超过 `max(增量均值绝对值 × 1%, 无技能总分均值 × 0.1%)` 才停止。 +到上限仍不满足则 `seTargetMet: false`。确定性增量报一个 种子、标准误 0;四个种子恰好相等本身不能证明随机技能是确定性的。种子均值不是游戏的期望,真实种子分布未知,标准误也 不表示模型误差。交叉项可能使用更少种子,见各变体的 `crossSeeds`;无普通 kind 时该值为 0。达到标准误目标可能只是满足基线 0.1%的绝对目标,不代表达到增量 1%的相对精度,也不能据小增量均值的正负断言技能一定有益或有害。 @@ -176,6 +180,8 @@ ournotes-deck chart-stats --data deck-data.json --no-gekisou-aptitude -o baselin - 普通技能倍率与名次沿用线性式,名次增量每区间有取整差,由 `check` 验证。Just 率不足 100% 时,仅能给出无普通技能增量的 Just / Perfect 插值估计, 13005 转换、每 Just 的 2001 支援及 13002 的 Just 数变化不能按比例精确缩放。未测 Perfect 打法的交叉权重,因此普通技能非零时不提供低于 100% Just 的完整适性;Great 比例乘 `1 − 0.2q` 也只是近似。 +以上插值与缩放描述统计摘要的使用边界。给定逐音符判定的单局结果使用共享 `replay` API,按实际帧序、技能与 seed 运行模型;见[计算契约](docs/native-validation.md#共用模型的计算契约)。 + 库调用可用 `chart_stats_with` / `document_with` 与 `Options { seeds, aptitude: Some(AptitudeOptions { max_seeds, cross_seeds }) }`;`aptitude: None` 关闭适性。 不含谱面的 `DeckData` 也可以生成形状表,或直接调用 `aptitude_header(master, kinds, options)`。 diff --git a/docs/native-validation.en.md b/docs/native-validation.en.md new file mode 100644 index 0000000..f320e4e --- /dev/null +++ b/docs/native-validation.en.md @@ -0,0 +1,52 @@ +# Native and runtime validation + +Song statistics and per-note play calculation use the same Rust scoring model. Its reliability is checked in two ways: frame comparisons with the game's native implementation on matching inputs, and result comparisons between the shared Rust API and WebAssembly. These checks provide a reproducible scoring basis for song data and subsequent deck evaluation and optimization. + +## Executing the native binary + +The offline harness runs client **1.0.1-25** `libil2cpp.so` machine code in the Unicorn ARM64 CPU emulator, using TW master `0b21c9f4a3911370d5f981fb894c7544`. Chart conversion, LiveExecutor, SkillStatus, SkillExecutor triggers and member routing, effect pools, skill conditions, randomness and score appliers execute the client's code. Card-skill cases use real card, skill and support configurations through the complete activation chain. + +The host supplies a virtual file system and selected libc and Unity services. IL2CPP reflection adapts Unity JsonUtility object construction; two localization-only getters return text keys. Stand-ins do not manufacture scoring arithmetic, skill-condition outcomes, triggers or judgement results. The harness operates without the full Android client or official servers. Ending animations and some mission containers use external adapters, documented in the evidence records. + +The main comparison chain supplies controlled grades and timing parameters to native auto-input, then records the **actual** original and converted results and their order. Rust consumes the native result stream at the same frame times and independently derives chart skill events. Integers must match exactly; declared float32 fields are compared by their raw bits. Frames, bounds and failing fields are not adjusted to obtain agreement. Pool identities, inactive states, execution times and score observations before and after ranking are checked as well as final scores. + +## Verified results + +The snapshot below is dated **2026-09-30**. Field counts include repeated frames, empty states and pool identities; they are not statistically independent samples. Some records and rechecks reuse a capture, so the rows are **not added into one experiment total**. + +| Case / input | Checked scope | Result | Evidence and applicability | +|---|---|---|---| +| No skills, Gekisou off | 5,500 frames, 71,500 field checks; six float32 factors | Zero differences | [Base coverage](validation/native-coverage-2026-09-30.json); one chart and fresh, no-rewind input | +| Five ordinary skill-1 members; Perfect and mixed accuracy | 5,500 frames and 253,000 core checks per case; native triggers, pools and applier dispatch | Zero differences | [Base coverage](validation/native-coverage-2026-09-30.json); unconditional 2000 family | +| Real skills 1/4/6/7/5; high/medium/low accuracy × 30/60/120 fps | Nine cases, 57,750 frames, 18,826,500 core checks; 35 effect-pool instances | Zero differences; compared float32 bits equal | [Base coverage](validation/native-coverage-2026-09-30.json); actual native Great/Good/Bad/Miss results and selected target/life conditions | +| Nonzero per-note timing offsets | 5,500 frames, 1,793,000 core checks; native P274/Great58/Good32 | Zero differences | [Base coverage](validation/native-coverage-2026-09-30.json); native window classification, completed results consumed by Rust | +| Life condition 700→600 | 5,500 frames, 2,288,000 core checks; 45 effect-pool instances | Zero differences; the active high-life effect continues | [Base coverage](validation/native-coverage-2026-09-30.json); selection threshold and active-effect state | +| High-level real ordinary skills | 5,500 frames, 1,545,500 core checks; actual float32 skill factors | Zero differences | [Base coverage](validation/native-coverage-2026-09-30.json); skills 3/5/1/2/7 at level 5 | +| Card Gekisou skills and supports; eight accuracy/fps configurations | 48,750 frames, 35,777,250 checks; pools, conversion, combo protection, cumulative Just, luck gauge and rush | Zero differences in declared fields | [Card coverage](validation/native-cards-2026-09-30.json); specified real cards and supports | +| Dynamic Just windows; production public API | Reused 1,600-frame capture; 9 timing types, 39 units; 388,800 window + 62,400 other field checks | **451,200 checks, zero differences** | [Production checks](validation/production-checks-2026-09-30.json); enable state, widths, both score stages, life, combo, RNG and ranges; no additional native capture | +| Probability condition 4011; GK20 and support 66 | Two cases, 15,000 frames, 10,680,000 core + 72 probability operand/result checks; high life and life 600 | Zero differences | [Probability coverage](validation/probability-4011-2026-09-30.json); seed 14 targets a real 5% successful branch for branch coverage | +| Solo Gekisou combo / Just / luck; no card skills | Three charts, 19,000 frames, 646,000 integer checks | Zero differences in declared fields | [Base coverage](validation/native-coverage-2026-09-30.json); both score channels, life, combo, random count and range states | +| Offline multiplayer combo / Just / luck; mixed results and life zero | 19,000 frames, 646,000 core checks; 76,000 derived-ranking checks listed separately | Zero differences | [Mode coverage](validation/native-modes-2026-09-30.json); no card skills; derived rankings are distinguished from production observations | +| Multiplayer ranking subsystem | 572 native invocation cases, 15,259 exact checks; ties, secondary measures and input boundaries | Zero differences | [Mode coverage](validation/native-modes-2026-09-30.json); invocation cases are not 572 whole lives | +| Challenge: no assist / FullCombo / AllPerfect | Three cases, 5,858 frames, 1,382,503 core checks; native PlayingState enter/update/exit | Zero differences in declared fields | [Mode coverage](validation/native-modes-2026-09-30.json); one whole chart and two short assist-branch sequences; animations use external completion | +| Mission / SoloGekisou; mixed results, combo breaks and life zero | 5,502 frames, 1,469,049 core checks; 22,008 derived-ranking checks listed separately | Zero differences; frame-display and calculator scores checked separately | [Mode coverage](validation/native-modes-2026-09-30.json); mission-container stand-ins and settlement boundaries are documented | +| Long charts and multiple judgements per frame; mixed accuracy/fps | Four real charts, seven cases, 70,747 frames, 19,971,785 field checks; 30 pool instances per frame | Zero differences; compared float32 max ULP 0 | [Long-chart coverage](validation/native-longcharts-2026-09-30.json); portability reruns add no independent native sample | +| One raw Touch input; private resource-adapter validation | 1,239 frames, 50 judgement events, 629,186 field checks; actual P/Great/Good/Bad/Miss | Zero differences | [Touch sample scope](validation/native-touch-2026-09-30.md); per-note area resources and finalization adapters are private validation extensions, not a release of complete Touch support | +| Shared Rust API → WebAssembly | 340 charts × 2 judgement plans × 2 modes: 1,360 matching runs | Zero differences; deterministic repeats and unknown-note rejection | [Runtime coverage](validation/replay-2026-09-30.json); normal and SoloGekisou scores, life, observed frame-max combo, judgement totals and Gekisou ranges; no additional ARM64 execution | +| Free / Solo Gekisou / challenge / mission / battle / arena / tutorial | Seven native settings and chart-creation branches | Native entry execution completed | [Mode coverage](validation/native-modes-2026-09-30.json); whole-play equality is limited to the cases in the other rows | + +## Shared model contract + +Per-note replay accepts completed judgements or full `RawResult` values, with explicit frame times, original result order, skills, random seed and ranking policy. Accuracy percentages alone do not determine those inputs. The page calculates a declared play by running the shared Rust model. Statistical baselines, weights and standard errors are separate outputs, not replacements for a per-play result. The full model already supports condition 4011 and executes probability skills and interactions in their actual draw order; a finite seed check does not estimate their population distribution. + +Score-operation registration is separate from life, combo and callback processing. Inactive conditional-pool execution and finish times are -1. Just missions switch Just enable state before raw grading and restore the client setting at the end. Audio/skill duration and score-table duration have independent sources: the latter is the native last-note position time plus 1,000 ms. Aptitude sampling checks the standard errors and respective baselines of both the best-grade and Perfect endpoints, preserving the existing rounding bounds and stopping thresholds. + +Solo ranking queries the calculator at range times; Network ranking uses controller frame snapshots. `frame_score()` is the pre-ranking display cache and `score()` is the post-ranking calculator value. They are distinct observation stages. Fixed-rank statistics use `new_gekisou_ranked` with Solo score-query semantics; actual Network simulation uses `new_gekisou_external` snapshots. Fixed-rank estimates do not simulate opponents or official server confirmations. + +The frozen scoring-model snapshot passes **272 release tests**, all-target clippy with denied warnings, fmt and Rust **1.88** all-target checks, with stable source identities during each check. The [production record](validation/production-checks-2026-09-30.json) contains the tested source, CLI and log hashes and their relationship to final release artifacts. Native evidence and WASM artifacts each retain their own fingerprints; Git HEAD, a model-source snapshot, the game ELF and a runtime memory image are different identities. + +## Scope and public materials + +The results support equivalence on the declared resources, inputs, fields and phases. Released independent physical-touch handling, every card interaction, repeated-trigger pool exhaustion, complete failure/continue/exit flows, device projection, online IFix patches and server rewards require their own evidence. Offline stepping after life zero constrains scoring state only. Some global-combo, range-accuracy, conditional-parent, elapsed and countDecrease fields are outside particular contracts, as recorded in each coverage file. + +Public materials contain methodology, scoped summaries, SHA identities, strict comparison tools and synthetic regressions. Safe indexes preserve [developer verification records](validation/native-cards-2026-09-30.json) and the [two-duration input contract](validation/two-length-resolution-2026-09-30.json), without game binaries, master contents, chart assets or private traces. See the [resource identity record](validation/native-source-2026-09-30.json). The [public comparator](../tools/native-validation/README.md) requires externally supplied traces and a field contract; missing inputs report `not_run`, and missing fields report `incomplete`. diff --git a/docs/native-validation.md b/docs/native-validation.md new file mode 100644 index 0000000..1589ca4 --- /dev/null +++ b/docs/native-validation.md @@ -0,0 +1,52 @@ +# 原生与运行时验证 + +歌曲统计与逐音符单局计算共用 Rust 计分模型。其可靠性通过两类核对建立:在相同输入下与游戏原生实现逐帧比较,以及核对同一 Rust API 在本机与 WebAssembly 中的结果。这为歌曲数据和后续编成比较、优化提供可复核的计分基准。 + +## 原生二进制怎样运行 + +离线验证使用 Unicorn ARM64 CPU 模拟器执行客户端 **1.0.1-25** 的 `libil2cpp.so`,主数据为 TW `0b21c9f4a3911370d5f981fb894c7544`。谱面转换、LiveExecutor、SkillStatus、SkillExecutor 的触发与成员分配、效果池调度、技能条件、随机数和计分应用执行客户端机器码。卡上技能检查使用真实成员卡、技能及支援配置,经完整触发链发动效果。 + +宿主环境提供文件系统、部分 libc 和 Unity 服务替身;IL2CPP 反射适配负责 Unity JsonUtility 对象装配,两个本地化展示 getter 返回文本 key。计分算术、技能条件、触发结果和判定结果不由替身编造。验证环境不依赖完整 Android 客户端或官服,结束动画与部分任务容器使用外部适配;具体替身记录在各证据清单中。 + +主验证链向原生自动输入传入受控判定与时间参数,并记录**实际输出**的原始判定、转换后判定和顺序。Rust 在相同帧时刻消费原生判定流,独立从谱面生成技能事件。整数必须完全相等,声明的 float32 字段按原始位模式比较;不移动帧,不放宽误差界,不删除失败字段。逐帧检查还覆盖效果池身份、未发动池、启停时间和排名前后取分阶段,因此能核对终分相等所不能单独保证的更新顺序。 + +## 已核对结果 + +下表按输入与验证层列出结果,日期为 **2026-09-30**。字段比较数包含重复帧、空状态与池身份,不是独立统计样本数。不同清单和复验可能复用同一捕获,**不合并为一个总实验数**。 + +| 场景 / 输入 | 核对范围 | 结果 | 证据与适用范围 | +|---|---|---|---| +| 无技能、撃奏关闭 | 5,500 帧、71,500 字段比较;六项 float32 因子 | 零差异 | [基础清单](validation/native-coverage-2026-09-30.json);指定谱面与无回退输入 | +| 五成员普通技能 1;Perfect 与混合准度 | 每例 5,500 帧、253,000 核心比较;真实触发、池与应用分派 | 零差异 | [基础清单](validation/native-coverage-2026-09-30.json);无条件 2000 家族 | +| 真实技能 1/4/6/7/5;高、中、低准度 × 30/60/120 fps | 九组 57,750 帧、18,826,500 核心比较;35 个效果池实例 | 零差异;所比较 float32 位模式一致 | [基础清单](validation/native-coverage-2026-09-30.json);原生实际含 Great/Good/Bad/Miss,覆盖指定目标与生命条件 | +| 非零逐音符击打偏移 | 5,500 帧、1,793,000 核心比较;原生实际 P274/Great58/Good32 | 零差异 | [基础清单](validation/native-coverage-2026-09-30.json);原生窗口分类,Rust 消费完成判定 | +| 生命条件 700→600 | 5,500 帧、2,288,000 核心比较;45 个效果池实例 | 零差异;已发动的高生命效果继续执行 | [基础清单](validation/native-coverage-2026-09-30.json);选择门槛与效果持续状态 | +| 高等级真实普通技能 | 5,500 帧、1,545,500 核心比较;实际 float32 技能因子 | 零差异 | [基础清单](validation/native-coverage-2026-09-30.json);指定技能 3/5/1/2/7、等级 5 | +| 卡上撃奏与支援;八组准度及帧率配置 | 48,750 帧、35,777,250 比较;效果池、转换、连击保护、累计 Just、幸运槽与 rush | 声明字段零差异 | [卡技能清单](validation/native-cards-2026-09-30.json);指定真实卡与支援配置 | +| Just 动态窗口;生产公共 API | 复用 1,600 帧原生捕获;9 类 timing、39 个 unit;388,800 窗口字段 + 62,400 其他字段 | **451,200 比较,零差异** | [生产检查](validation/production-checks-2026-09-30.json);窗口启闭、宽度、分数双阶段、生命、连击、RNG 与区间;不增加原生采集数 | +| 4011 概率触发;GK20 与支援 66 | 两组 15,000 帧、10,680,000 核心比较 + 72 概率操作数/结果比较;高生命与生命 600 分支 | 零差异 | [概率清单](validation/probability-4011-2026-09-30.json);seed 14 定向覆盖真实 5% 成功分支,属于分支覆盖 | +| 单人撃奏:连击 / Just / 幸运;无卡上技能 | 三谱 19,000 帧、646,000 整数比较 | 声明字段零差异 | [基础清单](validation/native-coverage-2026-09-30.json);双分数、生命、连击、随机次数与区间状态 | +| 离线多人:连击 / Just / 幸运;混判与生命归零 | 19,000 帧、646,000 核心比较;另列 76,000 排名推导检查 | 零差异 | [模式清单](validation/native-modes-2026-09-30.json);无卡上技能;派生排名与生产观察字段区分 | +| 多人排名子系统 | 572 组原生调用、15,259 精确比较;同分、次指标和输入边界 | 零差异 | [模式清单](validation/native-modes-2026-09-30.json);调用案例不是 572 局演出 | +| Challenge:无辅助 / FullCombo / AllPerfect | 三组 5,858 帧、1,382,503 核心比较;原生 PlayingState 进入、更新与退出 | 声明字段零差异 | [模式清单](validation/native-modes-2026-09-30.json);一组完整谱面与两组辅助分支短序列,动画采用外部完成 | +| Mission / SoloGekisou;混判、断连与生命归零 | 5,502 帧、1,469,049 核心比较;另列 22,008 排名推导检查 | 零差异;同帧显示分与计算器分数分别一致 | [模式清单](validation/native-modes-2026-09-30.json);任务容器替身与结算边界按清单记录 | +| 长谱与同帧多判定;混合准度及帧率 | 四张真实谱面、七组 70,747 帧、19,971,785 字段比较;每帧 30 个效果池实例 | 零差异;所比较 float32 最大 ULP 为 0 | [长谱清单](validation/native-longcharts-2026-09-30.json);移植复跑不增加独立原生样本 | +| 同一原始 Touch 输入;私有资源适配验证 | 1,239 帧、50 个判定事件、629,186 字段检查;实际 P/Great/Good/Bad/Miss | 零差异 | [触控样本范围](validation/native-touch-2026-09-30.md);逐音符区域资源与收尾适配属于私有验证扩展,不代表发布了完整 Touch 能力 | +| 共享 Rust API → WebAssembly | 340 谱 × 2 种判定计划 × 2 模式,共 1,360 组同源结果核对 | 零差异;重复运行一致;未知音符拒绝 | [运行时清单](validation/replay-2026-09-30.json);普通与 SoloGekisou,核对分数、生命、逐帧最大连击、判定统计与撃奏区间;不是新增 ARM64 实验 | +| 自由 / 单人撃奏 / Challenge / Mission / 多人 / Arena / 教程 | 七种原生设置入口及谱面创建分支 | 原生入口执行完成 | [模式清单](validation/native-modes-2026-09-30.json);整局字段相等范围以其他行的实际场景为准 | + +## 共用模型的计算契约 + +逐音符重放以完成判定或完整 `RawResult` 为输入,明确帧时刻、原始判定顺序、技能配置、随机 seed 和排名策略。准度百分比不能唯一确定这些输入。页面的单局结果由共享 Rust 引擎实际运行产生;统计基线、权重和标准误是另一类输出,不替代单局计算。4011 条件已经由完整模型处理,概率技能和条件交互按实际随机调用顺序运行;有限 seed 的通过不构成概率分布估计。 + +计分操作门控与生命、连击、回调处理分别保留;未执行条件池的执行与结束时间为 -1。Just 任务在原始判定前切换 Just 开关,结束时恢复客户端设置。音频/技能长度与计分表长度有独立来源:后者取原生最后音符位置的时间加 1,000 ms。统计适性的采样停止同时检查最高判定端和 Perfect 端的标准误及各自基线,保持原有取整界和阈值。 + +单人排名会按区间时间查询计算器,多人排名使用控制器冻结的帧快照。`frame_score()` 表示排名前的显示缓存,`score()` 表示排名后的计算器值,不能混成一个观察阶段。固定名次统计使用 `new_gekisou_ranked` 的 Solo 取分定义;真实 Network 模拟使用 `new_gekisou_external` 的帧快照,固定名次估计不模拟对手或官服确认。 + +计分模型的冻结快照通过 **272 项 release 回归**、全目标 clippy 零警告、fmt 检查及 Rust **1.88** 全目标兼容检查;源文件在检查期间稳定。[生产检查清单](validation/production-checks-2026-09-30.json)记录受测源码、CLI 和日志 SHA,并明确其与最终发布产物的身份关系。每份原生证据与 WASM 产物另有自己的来源指纹,Git HEAD、模型源快照、游戏二进制与运行期映像不能互相代替。 + +## 适用范围与公开材料 + +这些结果支持声明资源、输入与阶段上的模型一致性。独立物理触控的发布能力、全部卡牌交互、重复触发池耗尽、完整失败/继续/退场流程、设备投影、线上 IFix 及服务器奖励需要各自证据。生命归零后的离线继续步进仅约束计分状态。部分全局最大连击、区间准确率、条件父状态、elapsed/countDecrease 等字段尚未纳入对应契约,具体以各清单为准。 + +公开仓库提供方法、范围摘要、来源 SHA、严格比较器和合成回归,保留[开发核验记录](validation/native-cards-2026-09-30.json)及[双长度输入契约](validation/two-length-resolution-2026-09-30.json)的安全索引;不携带游戏二进制、master 内容、原始谱面或私有轨迹。输入身份见[资源来源清单](validation/native-source-2026-09-30.json)。[公开比较工具](../tools/native-validation/README.md)要求外部轨迹与字段契约;缺输入报告 `not_run`,缺字段报告 `incomplete`。 diff --git a/docs/validation/native-cards-2026-09-30.json b/docs/validation/native-cards-2026-09-30.json new file mode 100644 index 0000000..c30a18b --- /dev/null +++ b/docs/validation/native-cards-2026-09-30.json @@ -0,0 +1,867 @@ +{ + "status": "equal_declared_fields", + "passedRows": [ + { + "id": "card-just-pilot-state-fixed", + "label": "Real Just cards and cumulative Just", + "chartId": 10000201, + "fps": 60, + "frames": 3000, + "checks": 1551000, + "differences": 0, + "effectPoolInstances": 50, + "nativeTraceSha256": "d15ae5a17e63a47594404331e32421dbbbdb5473cc51c69fd3ef70a351a39c0b", + "rustTraceSha256": "ff87b18c076d44e7df3bb569d2b2cbec494674a4dc127ba2d66dbe7d9731ba27", + "comparisonSha256": "5b4a75bf403164867f788ed8962bcfa66d6ef6893e8d0f23e68a4728e1f94478", + "inputSequenceSha256": "55808d959771fe2125b1065cc6062615562f74e9e2f4c7d53f45f198fa5e7e74", + "fieldsCompared": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "settledScore" + ], + "rangeFieldsCompared": [ + "state", + "combo", + "maxCombo", + "justCount", + "startScore", + "endScore", + "luckPoints", + "luckGauge", + "rushCombo" + ], + "skillFieldsCompared": [ + "skillType", + "index", + "poolIndex", + "effectId", + "state", + "executeMs", + "finishMs", + "extendedBits", + "cumulativeCount" + ], + "actualOriginJudgements": { + "5": 144, + "6": 35 + }, + "actualConvertedJudgements": { + "5": 144, + "6": 35 + }, + "actualConversionCounts": {}, + "fieldsMissing": [ + "maxCombo", + "judgedCount", + "randomDraws", + "rawJustCount", + "judgedCount", + "float32Bits.accuracyAverage", + "conditional-skill parent skillState", + "elapsedBits", + "countDecrease" + ], + "factorStages": "single native post-applier/pre-ranking snapshot; later dual-stage cases qualify ranking-stage differences", + "nativeFinal": { + "score": 1740885, + "settledScore": 1740885, + "combo": 179, + "life": 1000 + }, + "musicLengthMs": 91667, + "dynamicWindowUnitsCompared": 0, + "rngDrawCountCompared": false + }, + { + "id": "card-combo-support-low", + "label": "Combo cards, grade conversion and combo protection / low accuracy", + "chartId": 10000103, + "fps": 60, + "frames": 5500, + "checks": 3833500, + "differences": 0, + "effectPoolInstances": 70, + "nativeTraceSha256": "200228d8c109396fff37949b71df684e68c82cbdeb2bffe9e0b400af81f35502", + "rustTraceSha256": "ff235aceccda5f1b394dcdf7a81fa3e4fe882e2aaf401c6f2ef5235fdedbf881", + "comparisonSha256": "bc58ce1f145be8491c5ffc93657afdd82541e918a1416edd68a0f6376f979786", + "inputSequenceSha256": "527f7c00382dc5f7bbf29370865d9b632233500029b2b0744ecba60adc2ba128", + "fieldsCompared": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "settledScore" + ], + "rangeFieldsCompared": [ + "state", + "combo", + "maxCombo", + "justCount", + "startScore", + "endScore", + "luckPoints", + "luckGauge", + "rushCombo" + ], + "skillFieldsCompared": [ + "skillType", + "index", + "poolIndex", + "effectId", + "state", + "executeMs", + "finishMs", + "extendedBits", + "cumulativeCount" + ], + "actualOriginJudgements": { + "1": 103, + "2": 108, + "3": 111, + "4": 106, + "5": 301, + "7": 54 + }, + "actualConvertedJudgements": { + "1": 103, + "2": 108, + "3": 99, + "4": 97, + "5": 322, + "7": 54 + }, + "actualConversionCounts": { + "4->5": 9, + "3->5": 12 + }, + "fieldsMissing": [ + "maxCombo", + "judgedCount", + "randomDraws", + "rawJustCount", + "judgedCount", + "float32Bits.accuracyAverage", + "conditional-skill parent skillState", + "elapsedBits", + "countDecrease" + ], + "factorStages": "single native post-applier/pre-ranking snapshot; later dual-stage cases qualify ranking-stage differences", + "nativeFinal": { + "score": 419698, + "settledScore": 419698, + "combo": 88, + "life": 0 + }, + "musicLengthMs": 91667, + "dynamicWindowUnitsCompared": 0, + "rngDrawCountCompared": false + }, + { + "id": "card-just-support-low-phase2", + "label": "Just cards and cumulative support / low accuracy", + "chartId": 10000201, + "fps": 60, + "frames": 5500, + "checks": 4361500, + "differences": 0, + "effectPoolInstances": 80, + "nativeTraceSha256": "599d74bf82ad771feb39f41199e0c1951e1a869b252143b2f5b00fc9559d9668", + "rustTraceSha256": "59dc8f4a217fc3994971a8554dc8a00b844b755308dc2f8096e9c34d615a1540", + "comparisonSha256": "866949dbc99044296f69c12ea3308366429c9863e42e36bb64283639bc2ab390", + "inputSequenceSha256": "90646aad9a934fe637d5c46dcf637cfde5fd318dc93b8a296cafbad92e557e04", + "fieldsCompared": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "settledScore" + ], + "rangeFieldsCompared": [ + "state", + "combo", + "maxCombo", + "justCount", + "startScore", + "endScore", + "luckPoints", + "luckGauge", + "rushCombo" + ], + "skillFieldsCompared": [ + "skillType", + "index", + "poolIndex", + "effectId", + "state", + "executeMs", + "finishMs", + "extendedBits", + "cumulativeCount" + ], + "actualOriginJudgements": { + "1": 72, + "2": 73, + "3": 73, + "4": 73, + "5": 58, + "6": 15 + }, + "actualConvertedJudgements": { + "1": 72, + "2": 73, + "3": 73, + "4": 73, + "5": 58, + "6": 15 + }, + "actualConversionCounts": {}, + "fieldsMissing": [ + "maxCombo", + "judgedCount", + "randomDraws", + "rawJustCount", + "judgedCount", + "float32Bits.accuracyAverage", + "conditional-skill parent skillState", + "elapsedBits", + "countDecrease" + ], + "factorStages": "before and after ranking", + "nativeFinal": { + "score": 502768, + "settledScore": 502768, + "combo": 0, + "life": 0 + }, + "musicLengthMs": 91667, + "dynamicWindowUnitsCompared": 0, + "rngDrawCountCompared": false + }, + { + "id": "card-just-support-high-phase2", + "label": "Just cards, conversion and cumulative support / high accuracy", + "chartId": 10000201, + "fps": 60, + "frames": 5500, + "checks": 4361500, + "differences": 0, + "effectPoolInstances": 80, + "nativeTraceSha256": "7b0d1d24adf67b23087868a4cdbe51beda6ff6456285b83b233dfe7f5c603cb0", + "rustTraceSha256": "25996b1c6a8f7c369c629a873dabd2f6447fe8e3495d124dc4effad04e8627b7", + "comparisonSha256": "83b1586dc2960e7b70a046ed48b4f8caf7af565dc5856b03e6e24f87ba725714", + "inputSequenceSha256": "d92d3862e4cd6e5a6384d7dec09ca6c16c0edcc75d37f4405a55219a4ca00991", + "fieldsCompared": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "settledScore" + ], + "rangeFieldsCompared": [ + "state", + "combo", + "maxCombo", + "justCount", + "startScore", + "endScore", + "luckPoints", + "luckGauge", + "rushCombo" + ], + "skillFieldsCompared": [ + "skillType", + "index", + "poolIndex", + "effectId", + "state", + "executeMs", + "finishMs", + "extendedBits", + "cumulativeCount" + ], + "actualOriginJudgements": { + "5": 279, + "6": 85 + }, + "actualConvertedJudgements": { + "5": 277, + "6": 87 + }, + "actualConversionCounts": { + "5->6": 2 + }, + "fieldsMissing": [ + "maxCombo", + "judgedCount", + "randomDraws", + "rawJustCount", + "judgedCount", + "float32Bits.accuracyAverage", + "conditional-skill parent skillState", + "elapsedBits", + "countDecrease" + ], + "factorStages": "before and after ranking", + "nativeFinal": { + "score": 5195801, + "settledScore": 5195801, + "combo": 364, + "life": 1000 + }, + "musicLengthMs": 91667, + "dynamicWindowUnitsCompared": 0, + "rngDrawCountCompared": false + }, + { + "id": "card-luck-support-low-longmusic", + "label": "Luck cards, probability-up, gauge and score support / low accuracy / explicit long diagnostic length", + "chartId": 10000303, + "fps": 60, + "frames": 8000, + "checks": 5656000, + "differences": 0, + "effectPoolInstances": 70, + "nativeTraceSha256": "62731bb2981dae79bfa2382b28f26ca6b0491d1c222addb29e8b32241f64b527", + "rustTraceSha256": "33cc79249bfa1383e8ecf72b22739d1434434db357d01aec0f427352353ee048", + "comparisonSha256": "03265d4a0a042ea177d33f234b2b174e3284942da2fa74249ad94494342b854f", + "inputSequenceSha256": "1463b249622beb67cd1fc0857cc511721a52c0254bc1353c5d98578f082c291e", + "fieldsCompared": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "settledScore", + "randomDraws" + ], + "rangeFieldsCompared": [ + "state", + "combo", + "maxCombo", + "justCount", + "startScore", + "endScore", + "luckPoints", + "luckGauge", + "rushCombo", + "rawJustCount" + ], + "skillFieldsCompared": [ + "skillType", + "index", + "poolIndex", + "effectId", + "state", + "executeMs", + "finishMs", + "extendedBits", + "cumulativeCount" + ], + "actualOriginJudgements": { + "1": 152, + "2": 156, + "3": 160, + "4": 161, + "5": 383, + "7": 55 + }, + "actualConvertedJudgements": { + "1": 152, + "2": 156, + "3": 160, + "4": 161, + "5": 383, + "7": 55 + }, + "actualConversionCounts": {}, + "fieldsMissing": [ + "maxCombo", + "judgedCount", + "judgedCount", + "float32Bits.accuracyAverage", + "conditional-skill parent skillState", + "elapsedBits", + "countDecrease" + ], + "factorStages": "before and after ranking", + "nativeFinal": { + "score": 436522, + "settledScore": 436522, + "combo": 0, + "life": 0 + }, + "musicLengthMs": 133350, + "dynamicWindowUnitsCompared": 0, + "rngDrawCountCompared": true + }, + { + "id": "card-combo-support-medium30", + "label": "Combo cards and supports / medium accuracy / 30 Hz", + "chartId": 10000103, + "fps": 30, + "frames": 2750, + "checks": 1944250, + "differences": 0, + "effectPoolInstances": 70, + "nativeTraceSha256": "274007c6d293120f3d7f45c0b31c463b4632ba4bb0f75bef801f19e22ceb374d", + "rustTraceSha256": "6f5e9f07bffb6febfb359ab8b51ef426f6f8b2c27e56138f63661d30f703d7fb", + "comparisonSha256": "15fe959d8b668de88368999aeda836ead5de58976d46e96e3ee3597b8400a9b5", + "inputSequenceSha256": "459a4d11625013b3d748a527b7159b3b3d286558c2759261b70cb5689cc155b6", + "fieldsCompared": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "settledScore", + "randomDraws" + ], + "rangeFieldsCompared": [ + "state", + "combo", + "maxCombo", + "justCount", + "startScore", + "endScore", + "luckPoints", + "luckGauge", + "rushCombo", + "rawJustCount" + ], + "skillFieldsCompared": [ + "skillType", + "index", + "poolIndex", + "effectId", + "state", + "executeMs", + "finishMs", + "extendedBits", + "cumulativeCount" + ], + "actualOriginJudgements": { + "2": 56, + "3": 57, + "4": 157, + "5": 459, + "7": 54 + }, + "actualConvertedJudgements": { + "2": 56, + "3": 50, + "4": 143, + "5": 480, + "7": 54 + }, + "actualConversionCounts": { + "4->5": 14, + "3->5": 7 + }, + "fieldsMissing": [ + "maxCombo", + "judgedCount", + "judgedCount", + "float32Bits.accuracyAverage", + "conditional-skill parent skillState", + "elapsedBits", + "countDecrease" + ], + "factorStages": "before and after ranking", + "nativeFinal": { + "score": 1214536, + "settledScore": 1214536, + "combo": 95, + "life": 0 + }, + "musicLengthMs": 91667, + "dynamicWindowUnitsCompared": 0, + "rngDrawCountCompared": true + }, + { + "id": "card-just-support-medium120", + "label": "Just cards and supports / medium accuracy / 120 Hz", + "chartId": 10000201, + "fps": 120, + "frames": 11000, + "checks": 8767000, + "differences": 0, + "effectPoolInstances": 80, + "nativeTraceSha256": "9d62927ff5c29e86552751a7bad2552ec6a3730519bd4ce15d93c8f0707d93ca", + "rustTraceSha256": "68518e4fff6a84116cb65d88f74841754e30e3a6bc5d27b577b6b1e9cda254db", + "comparisonSha256": "ac813b864c6b2ce1d8a587c8df73fc6beca4f0c3114565cec45dac21c6e2187d", + "inputSequenceSha256": "bcc4b93f2f7351f9f77a7b2eba1dadbc741858b09be98ecb44ee335884fffcdf", + "fieldsCompared": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "settledScore", + "randomDraws" + ], + "rangeFieldsCompared": [ + "state", + "combo", + "maxCombo", + "justCount", + "startScore", + "endScore", + "luckPoints", + "luckGauge", + "rushCombo", + "rawJustCount" + ], + "skillFieldsCompared": [ + "skillType", + "index", + "poolIndex", + "effectId", + "state", + "executeMs", + "finishMs", + "extendedBits", + "cumulativeCount" + ], + "actualOriginJudgements": { + "2": 36, + "3": 73, + "4": 109, + "5": 113, + "6": 33 + }, + "actualConvertedJudgements": { + "2": 36, + "3": 73, + "4": 109, + "5": 113, + "6": 33 + }, + "actualConversionCounts": {}, + "fieldsMissing": [ + "maxCombo", + "judgedCount", + "judgedCount", + "float32Bits.accuracyAverage", + "conditional-skill parent skillState", + "elapsedBits", + "countDecrease" + ], + "factorStages": "before and after ranking", + "nativeFinal": { + "score": 1410535, + "settledScore": 1410535, + "combo": 6, + "life": 0 + }, + "musicLengthMs": 91667, + "dynamicWindowUnitsCompared": 0, + "rngDrawCountCompared": true + }, + { + "id": "card-luck-support-low-audio", + "label": "Luck cards and supports / low accuracy / actual ACB-derived audio length", + "chartId": 10000303, + "fps": 60, + "frames": 7500, + "checks": 5302500, + "differences": 0, + "effectPoolInstances": 70, + "nativeTraceSha256": "5b7840bb7c2a1ec750da716eab62c817bcd2741698d7380254b7e1725cceba01", + "rustTraceSha256": "ace9a04a26fa6621b31cda43bf69086228135870ad73f21e2e9d1d3be8478735", + "comparisonSha256": "f64140821f0b627655f678db3cf5ecd388a3d038bff98f873f8b4c1d22784f71", + "inputSequenceSha256": "1463b249622beb67cd1fc0857cc511721a52c0254bc1353c5d98578f082c291e", + "fieldsCompared": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "settledScore", + "randomDraws" + ], + "rangeFieldsCompared": [ + "state", + "combo", + "maxCombo", + "justCount", + "startScore", + "endScore", + "luckPoints", + "luckGauge", + "rushCombo", + "rawJustCount" + ], + "skillFieldsCompared": [ + "skillType", + "index", + "poolIndex", + "effectId", + "state", + "executeMs", + "finishMs", + "extendedBits", + "cumulativeCount" + ], + "actualOriginJudgements": { + "1": 152, + "2": 156, + "3": 160, + "4": 161, + "5": 383, + "7": 55 + }, + "actualConvertedJudgements": { + "1": 152, + "2": 156, + "3": 160, + "4": 161, + "5": 383, + "7": 55 + }, + "actualConversionCounts": {}, + "fieldsMissing": [ + "maxCombo", + "judgedCount", + "judgedCount", + "float32Bits.accuracyAverage", + "conditional-skill parent skillState", + "elapsedBits", + "countDecrease" + ], + "factorStages": "before and after ranking", + "nativeFinal": { + "score": 436522, + "settledScore": 436522, + "combo": 0, + "life": 0 + }, + "musicLengthMs": 120557, + "dynamicWindowUnitsCompared": 0, + "rngDrawCountCompared": true + }, + { + "id": "card-just-window-pilot-enabled-fixed", + "label": "Raw runtime Just enable and dynamic percent windows / existing high-accuracy pilot", + "chartId": 10000201, + "fps": 60, + "frames": 1600, + "checks": 1678400, + "differences": 0, + "effectPoolInstances": 80, + "nativeTraceSha256": "f866b89277cc0d868e569c9c92bb017ff7fbd44422e0597ff0c4c078cf2c6cef", + "rustTraceSha256": "12dce2d88696be67effa4c4896f54b206495a0a5512b8661a556057cd5a2e2b0", + "comparisonSha256": "04c5047219da7875f43e3bf1ffc2f869d557acb47e7c4e5edff9e19adf4eea61", + "inputSequenceSha256": "30fba182079db0b0de1af5840970c708e7fb2fe798316f2deebc16aa98d30a4f", + "fieldsCompared": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "settledScore", + "randomDraws" + ], + "rangeFieldsCompared": [ + "state", + "combo", + "maxCombo", + "justCount", + "startScore", + "endScore", + "luckPoints", + "luckGauge", + "rushCombo", + "rawJustCount" + ], + "skillFieldsCompared": [ + "skillType", + "index", + "poolIndex", + "effectId", + "state", + "executeMs", + "finishMs", + "extendedBits", + "cumulativeCount" + ], + "actualOriginJudgements": { + "5": 56, + "6": 19 + }, + "actualConvertedJudgements": { + "5": 56, + "6": 19 + }, + "actualConversionCounts": {}, + "fieldsMissing": [ + "maxCombo", + "judgedCount", + "judgedCount", + "float32Bits.accuracyAverage", + "conditional-skill parent skillState", + "elapsedBits", + "countDecrease" + ], + "factorStages": "before and after ranking", + "nativeFinal": { + "score": 855028, + "settledScore": 855028, + "combo": 75, + "life": 1000 + }, + "musicLengthMs": 91667, + "dynamicWindowUnitsCompared": 39, + "rngDrawCountCompared": true, + "historicalDynamicWindowUnitsAnnotation": { + "dynamicWindowUnitsCompared": 36, + "timingTypeCount": 6 + }, + "dynamicWindowTimingTypeCount": 9, + "dynamicWindowUnitCountsByType": { + "1": 6, + "2": 3, + "5": 6, + "10": 6, + "11": 5, + "12": 6, + "15": 3, + "21": 2, + "22": 2 + }, + "productionPublicApiRecheck": { + "record": "production-checks-2026-09-30.json", + "recordSha256": "4b9f8145c78891bf227520c0e6b577d2640bf22d03cd35aeca56ea464002b7ff", + "frames": 1600, + "timingTypeCount": 9, + "unitCount": 39, + "windowFieldComparisons": 388800, + "otherFieldComparisons": 62400, + "differenceCount": 0, + "driverSha256": "ce9631182a5690e8e0c9f7ac17a413350296d718d2a82e4f75a1656d67593664", + "nativeCaptureSha256": "f866b89277cc0d868e569c9c92bb017ff7fbd44422e0597ff0c4c078cf2c6cef", + "inputDataSha256": "ef87c0ba383ad3b72c2ddcf40455d7462c586fb1cd8d2abcc84ebdbbc0bda538", + "traceSha256": "2e5323c7a26f36b43a035eeb02b5d68de65a2fcb43483ff7c1f7e68de229b7ce", + "logSha256": "91146df9fb42919f015b0bec8b2dd0ce075495fa734c67133832de07dedca0ef", + "status": "equal_declared_fields_using_unchanged_production_library_public_apis", + "totalFieldComparisons": 451200, + "distinctNativeCapturesAdded": 0, + "productionSourceTreeSha256": "9d647e0527f1bbd2c0bf832f7536161ebd0399413ff208d800a8cd62cf1f5830", + "windowFields": [ + "judgement", + "enabled", + "baseBeforeMs", + "baseAfterMs", + "beforeMs", + "afterMs" + ], + "otherFieldGroups": [ + "frame-result score before ranking", + "calculator score after ranking", + "life", + "current combo", + "random draws", + "converted note-result order", + "six post-ranking float32 bit patterns", + "nine fields in each Gekisou range" + ], + "qualification": "Reuse the same 1600-frame native capture; replay native-origin NoteResult and originDiffMs through frame_raw_timed. No private observation hook or source patch is present in the production library. This does not claim independent physical-touch classification or repeat the existing full-pool experiment count." + }, + "recheckCounting": "The public-API recheck uses this same native capture and is not added to passingFrames, passingChecks or independent native experiment counts." + } + ], + "passingFrames": 50350, + "passingChecks": 37455650, + "differenceCount": 0, + "comparisonCounting": "Exact integer / uint32-f32 field equality, declared pool identities and array/order checks included; this is not a count of independent samples.", + "method": "Actual native MasterMemberCard, card and support skill rows; native SkillStatus, all pool instances, SkillExecutor phases and common/Gekisou appliers; native Solo ranking wrappers. Native grade origin/converted stream consumed by Rust; independent touch replay not performed.", + "fieldsNotCompared": [ + "global maxCombo and judgedCount", + "range judgedCount and accuracyAverage", + "conditional parent skillState", + "native-only elapsedBits/countDecrease" + ], + "qualifications": [ + "Timing-window geometry uses the actual nine timing types and all 39 units in one existing 1600-frame pilot. All six unit fields are required. The same capture also passes the unchanged production public API; native-origin results are consumed, not independently derived from touch input.", + "Offline stepping at life zero is not a complete failure/continue lifecycle.", + "Empty member-power dictionary; effects requiring ISkillMemberPower are not certified.", + "Card order and supports are explicit offline test inputs; ownership/profile legality is not tested.", + "No complete card-bearing Enter/Exit/UI/audio/network lifecycle." + ], + "failuresRetained": [ + { + "id": "unused-pool-timestamps", + "kind": "production_initialization_defect", + "firstFrame": 0, + "native": -1, + "rustBefore": 0, + "status": "fixed_and_same_capture_rechecked", + "initialDifferences": 123410 + }, + { + "id": "just-support-factor-stage", + "kind": "different_observation_stages", + "initialDifferences": 3, + "status": "both_native_and_rust_stages_explicitly_captured_and_rechecked" + }, + { + "id": "luck-short-music", + "kind": "probe_input_mismatch_between_audio_and_score_table_lengths", + "musicLengthMs": 91667, + "firstFrame": 6046, + "nativeSettledScore": 328726, + "rustSettledScore": 329321, + "checks": 5656000, + "differences": 5861, + "status": "resolvedProbeInputMismatch", + "historicalKind": "candidate_score_difference_with_past_clamped_effect_end", + "historicalStatus": "under_investigation", + "resolution": { + "record": "two-length-resolution-2026-09-30.json", + "recordSha256": "cafc198f8f70d573208fe6cecff9569ff69db6f93307f36b222ec497bb116633", + "sameNativeCaptureSha256": "d479871f12209d6c12d47772e7b4bba6a65c2033e0f2b2c773aff1e7bdfc2251", + "historicalComparisonSha256": "984efea1e7e051aad24130e986f8f38585ad6739226a586de33f7de8eff566cf", + "correctedComparisonSha256": "4ff7433a3144bfa918ad6dddf44160453a281b42e7942b0843a6b21737e7d3da", + "correctedRustTraceSha256": "b66f206247aa4d5f2250ec53e0dd25ad0096139f773698abae85584ef0b3b1a5", + "frames": 8000, + "fieldComparisons": 5656000, + "remainingDifferences": 0, + "addsIndependentNativeExperiment": false + } + }, + { + "id": "raw-just-enabled", + "kind": "production_raw_runtime_wiring_defect", + "firstFrame": 706, + "initialMismatchingFrames": 470, + "initialDifferences": 3484, + "status": "production_public_api_recheck_equal_declared_fields", + "checksAfterFix": 1678400, + "differencesAfterFix": 0, + "productionReview": "Integrated production repair and regressions pass the frozen 272-test suite; the same capture passes the public API with no production observation patch.", + "historicalStatus": "same_native_capture_rechecked_after_candidate_fix", + "historicalProductionReview": "model_audit owns production patch / regressions", + "productionRecheck": { + "record": "production-checks-2026-09-30.json", + "sourceTreeSha256": "9d647e0527f1bbd2c0bf832f7536161ebd0399413ff208d800a8cd62cf1f5830", + "frames": 1600, + "windowFieldComparisons": 388800, + "otherFieldComparisons": 62400, + "differences": 0, + "addsIndependentNativeExperiment": false + } + } + ], + "remainingFailureCount": 0, + "remainingFailureQualification": "Retained findings in this declared card contract are resolved or have explicit input/observation-stage resolutions. Zero remaining findings is not a claim of all-game equivalence; unobserved fields and coverage boundaries remain listed.", + "historicalSummarySha256": "714b1be7795b9a5fd4660d60d0bb7b3eca534fbd39de25c5459dd50e2d656153", + "historicalSummaryQualification": "Original public ledger fingerprint retained. Its trace and comparison hashes remain unchanged; geometry annotations and resolution status are updated below.", + "historicalRemainingFailureCount": 1, + "historicalWindowQualification": "Timing-window geometry (enabled + base and dynamic ms) is verified on one existing 1600-frame six-type / 36-unit high-accuracy pilot after the explicit raw-runtime fix. Its native-origin results are consumed; independent touch-derived grading and all window-skill families remain unverified.", + "historicalRemainingFailureQualification": "Explicit truncated music-length boundary (91667 ms versus chart ending later) still differs; normal actual ACB-derived length and long diagnostic length agree." +} diff --git a/docs/validation/native-coverage-2026-09-30.json b/docs/validation/native-coverage-2026-09-30.json new file mode 100644 index 0000000..5e717f9 --- /dev/null +++ b/docs/validation/native-coverage-2026-09-30.json @@ -0,0 +1,567 @@ +{ + "format": "ournotes-deck.native-coverage/1", + "status": "limited-sample-exact-comparison", + "updatedLocalDate": "2026-09-30", + "client": "1.0.1-25 ARM64", + "master": "tw/0b21c9f4a3911370d5f981fb894c7544", + "rows": [ + { + "id": "compare-10000103", + "chartId": 10000103, + "nativeFrames": 5500, + "coreComparisons": 187000, + "differences": 0, + "maxFloatUlp": null, + "fps": null, + "actualOrigin": null, + "actualConverted": null, + "inputSequenceSha256": null, + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "c0a40a2c995679665d49d837a95d260e43682fdc6221a3a0690b2b42e5ba4361", + "rustCaptureSha256": "5b98c5aa1ec88dc9563a94e0521b93b20e1ad2a4f33b9d1ddfabb05ec2b7eab2", + "comparisonSha256": "f3c44920f7e147918122e7899c9072ccfbe99f098e392835a657d96fbfdfbe91", + "contract": "solo-gekisou" + }, + { + "id": "compare-10000201", + "chartId": 10000201, + "nativeFrames": 5500, + "coreComparisons": 187000, + "differences": 0, + "maxFloatUlp": null, + "fps": null, + "actualOrigin": null, + "actualConverted": null, + "inputSequenceSha256": null, + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "288b2456017e7b33094c57b05b93cf31be603338296c6d429e3986703157f9cf", + "rustCaptureSha256": "8a09312a74ed452f4b7f6c484c2e6f87de8829eaabc6c4d662154a9d30933782", + "comparisonSha256": "a447567691a6da43a176d02158f324e5680895fb65fda5362fe755e821548b52", + "contract": "solo-gekisou" + }, + { + "id": "compare-10000303", + "chartId": 10000303, + "nativeFrames": 8000, + "coreComparisons": 272000, + "differences": 0, + "maxFloatUlp": null, + "fps": null, + "actualOrigin": null, + "actualConverted": null, + "inputSequenceSha256": null, + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "16b20b376fbe413bf885f70198edf79c2b1155a09e6ed039e925051dfe431913", + "rustCaptureSha256": "f49a283e46d463c1a9163d6e96eb55e9dffccaab7640faf9f342e44728a82f58", + "comparisonSha256": "914a10efa204844eff9d5eb4839e850deb71636d4c092f92443234ec4a30e160", + "contract": "solo-gekisou" + }, + { + "id": "compare-full-5-5500-mixed-v2", + "chartId": 10000201, + "nativeFrames": 5500, + "coreComparisons": 253000, + "differences": 0, + "maxFloatUlp": 0, + "fps": null, + "actualOrigin": null, + "actualConverted": null, + "inputSequenceSha256": null, + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "ec956c2402bc26b8dd798d8506d1940838db08d23d885d1522bc3fa56738509b", + "rustCaptureSha256": "241e216dc13a6be5200305f4e4e203fff147e670f5ddc0500e93ff49311d0997", + "comparisonSha256": "8e5ed4222ff037cbc8377b009687ab867c6f2670968ce3e0f388b29ad915acfa", + "contract": "ordinary-first-pool" + }, + { + "id": "compare-full-5-5500-perfect", + "chartId": 10000201, + "nativeFrames": 5500, + "coreComparisons": 253000, + "differences": 0, + "maxFloatUlp": 0, + "fps": null, + "actualOrigin": null, + "actualConverted": null, + "inputSequenceSha256": null, + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "989bd77fea1ccae985a6a12741ee85e3adf9e1ed4bd8b4ac7bfb699ec1a76110", + "rustCaptureSha256": "d7646c399bf109643d39fda7561352c568d09ceeda8a8c86b04c5567a0b7700c", + "comparisonSha256": "42b339646ae29482d71f1f37f707735be037ac6101be41a69d50467b8c7edc01", + "contract": "ordinary-first-pool" + }, + { + "id": "compare-matrix-factor-extremes-60", + "chartId": 10000201, + "nativeFrames": 5500, + "coreComparisons": 1545500, + "differences": 0, + "maxFloatUlp": 0, + "fps": 60, + "actualOrigin": { + "5": 328, + "4": 36 + }, + "actualConverted": { + "5": 328, + "4": 36 + }, + "inputSequenceSha256": "e5953064f11f9bf60c9ce26b96d603df9b16bb668eb8ee581edca5655a2659c5", + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "798a3a639f71bdf5ffa8cdb66175f791509c52d22f03f94fe1e63c2aa5b7d622", + "rustCaptureSha256": "b3017b07c29886d9281bb1ac02210d68e5c14bf9f265fea10928560ce05ac652", + "comparisonSha256": "14636223028f95f0e24b3aacda9ec896a02ab0149cab0e0d210a8f67ec6067f1", + "contract": "ordinary-full-pool" + }, + { + "id": "compare-matrix-high-120", + "chartId": 10000201, + "nativeFrames": 11000, + "coreComparisons": 3586000, + "differences": 0, + "maxFloatUlp": 0, + "fps": 120, + "actualOrigin": { + "5": 328, + "4": 36 + }, + "actualConverted": { + "5": 328, + "4": 36 + }, + "inputSequenceSha256": "b2c4177b3663611825e6a333800fec3b26d02308a8185710b75f90c2fc9693e3", + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "36faad127c7947bba7b7d15f46468ab4a0bcccf53d43561034143e44b2e381c8", + "rustCaptureSha256": "76f67962040aa6d1a00847f3a4c1b87b8bf150aaec0bddb49b87f8b31dac0d25", + "comparisonSha256": "45d8f79e22507d558fdc99adfaf8ada078303829ad29e252d9bc0038bacb4f8f", + "contract": "ordinary-full-pool" + }, + { + "id": "compare-matrix-high-30", + "chartId": 10000201, + "nativeFrames": 2750, + "coreComparisons": 896500, + "differences": 0, + "maxFloatUlp": 0, + "fps": 30, + "actualOrigin": { + "5": 328, + "4": 36 + }, + "actualConverted": { + "5": 328, + "4": 36 + }, + "inputSequenceSha256": "f6c7f073500942b3734d0337b2bdb7267302d2b82c1ff64490d8463b5481d75a", + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "bb6e40f054cfa535fd72bd31478963a4eed90a48b383bc5c7e9e5cffa01f9d94", + "rustCaptureSha256": "7f5f1d8c8fc68d69f8ae8645a34b7654bd388a7ece97a5d75fbaa9eba8fd3215", + "comparisonSha256": "0faea406d14f9a6d259518e1273acfaec01d9a93213a3eb807e999484cc07889", + "contract": "ordinary-full-pool" + }, + { + "id": "compare-matrix-high-60", + "chartId": 10000201, + "nativeFrames": 5500, + "coreComparisons": 1793000, + "differences": 0, + "maxFloatUlp": 0, + "fps": 60, + "actualOrigin": { + "5": 328, + "4": 36 + }, + "actualConverted": { + "5": 328, + "4": 36 + }, + "inputSequenceSha256": "e5953064f11f9bf60c9ce26b96d603df9b16bb668eb8ee581edca5655a2659c5", + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "de3b6a9c9d0ca6d9a15539d33a9bbcc3e9aec32b6b759468187949ffff447b73", + "rustCaptureSha256": "8b57244bdd4611e177538966785f05e817423f26177508e252d701a0769af704", + "comparisonSha256": "b6da902d40ade48ac3b1d2ae906774046e11bc85b27f88f21f058daf5d37b1eb", + "contract": "ordinary-full-pool" + }, + { + "id": "compare-matrix-life700-60", + "chartId": 10000201, + "nativeFrames": 5500, + "coreComparisons": 2288000, + "differences": 0, + "maxFloatUlp": 0, + "fps": 60, + "actualOrigin": { + "1": 4, + "5": 360 + }, + "actualConverted": { + "1": 4, + "5": 360 + }, + "inputSequenceSha256": "8b2c22636fc64c39989dc5a91e68178d35f28b93fcad06e5efb2a82c26199220", + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "e93009787e6c4042d79c81dd0b626736a991b2b564d862e8e19fc4085c4f0a8a", + "rustCaptureSha256": "9275014b005777d82930aa9d7d271d4c64e22657d1364f4d7e22c445e7400687", + "comparisonSha256": "d50129d0d821ec4aa64d10d69fb7b836756235bcc9db6f99dc4fb93e39a1659e", + "contract": "ordinary-full-pool" + }, + { + "id": "compare-matrix-low-120", + "chartId": 10000201, + "nativeFrames": 11000, + "coreComparisons": 3586000, + "differences": 0, + "maxFloatUlp": 0, + "fps": 120, + "actualOrigin": { + "5": 73, + "4": 73, + "3": 73, + "2": 73, + "1": 72 + }, + "actualConverted": { + "5": 73, + "4": 73, + "3": 73, + "2": 73, + "1": 72 + }, + "inputSequenceSha256": "5cf288e46efd3db706e6573815242abb61da53cb469cc4bb76dc9e65d1551ce2", + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "bb0284bab7091a8592e225976f6ad96806f006ef1ebddcb8e2a179417a008e2e", + "rustCaptureSha256": "ef5159388af4e6f691f592c04ebc4f9d6e19bb5534a1f5e5bd4126848844a1f6", + "comparisonSha256": "a9d26a11acb300fbf2b8615a632f05a25bdae96fe047318aa8c39814112c3e09", + "contract": "ordinary-full-pool" + }, + { + "id": "compare-matrix-low-30", + "chartId": 10000201, + "nativeFrames": 2750, + "coreComparisons": 896500, + "differences": 0, + "maxFloatUlp": 0, + "fps": 30, + "actualOrigin": { + "5": 73, + "4": 73, + "3": 73, + "2": 73, + "1": 72 + }, + "actualConverted": { + "5": 73, + "4": 73, + "3": 73, + "2": 73, + "1": 72 + }, + "inputSequenceSha256": "754deec2ced3a73c9b7ddea8814a7e648dbde2ca4b099e48074a79c29bda218b", + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "ba72a7b367956e7b8279a8a4c3f8d81723abab998cf85dda91fcdd1bb0388a2b", + "rustCaptureSha256": "6558141c8fe28210a5f2e4d2c7f3eb8eb6aae545f1b7943ac17c3316d80d9bfa", + "comparisonSha256": "dd7a0f183578017376dbb53cf59a902723cc8ab418b58b2cc03a5c6ad00dadf4", + "contract": "ordinary-full-pool" + }, + { + "id": "compare-matrix-low-60", + "chartId": 10000201, + "nativeFrames": 5500, + "coreComparisons": 1793000, + "differences": 0, + "maxFloatUlp": 0, + "fps": 60, + "actualOrigin": { + "5": 73, + "4": 73, + "3": 73, + "2": 73, + "1": 72 + }, + "actualConverted": { + "5": 73, + "4": 73, + "3": 73, + "2": 73, + "1": 72 + }, + "inputSequenceSha256": "3157f197085e9ee5dc7ec9469247defd745cefe855690c70e12cc058d90c7325", + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "cf03ad55698f52d3e9316bcb2c098bf5f4b3a5e3df1c36027e40e411ad7d5bf4", + "rustCaptureSha256": "02146b3f6387088703ef59b128af917ceebb29adfe4747260b3a9047c3e3b756", + "comparisonSha256": "cef7f4ab432342d3bd8ecd2f61f69617b4529bd63830ee878759abf9be99275c", + "contract": "ordinary-full-pool" + }, + { + "id": "compare-matrix-medium-120", + "chartId": 10000201, + "nativeFrames": 11000, + "coreComparisons": 3586000, + "differences": 0, + "maxFloatUlp": 0, + "fps": 120, + "actualOrigin": { + "5": 146, + "4": 109, + "3": 73, + "2": 36 + }, + "actualConverted": { + "5": 146, + "4": 109, + "3": 73, + "2": 36 + }, + "inputSequenceSha256": "acb6adf2fe68af534397fdde965817b6f58416cf1f16cd78e335601cbd53640d", + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "aa2baf94444c4f1d9276b38d2ef674fffeef283f372f882011aebb283531d687", + "rustCaptureSha256": "20c7cd95eef10743f822f12f49b4ce68fe849d83a003cc661d0b7a45b389a53a", + "comparisonSha256": "8e8f4fe140918c6cfd92270c42fa7c4cdffe382c44389d2cdd21520c0c98c10a", + "contract": "ordinary-full-pool" + }, + { + "id": "compare-matrix-medium-30", + "chartId": 10000201, + "nativeFrames": 2750, + "coreComparisons": 896500, + "differences": 0, + "maxFloatUlp": 0, + "fps": 30, + "actualOrigin": { + "5": 146, + "4": 109, + "3": 73, + "2": 36 + }, + "actualConverted": { + "5": 146, + "4": 109, + "3": 73, + "2": 36 + }, + "inputSequenceSha256": "0a61d69ea1c98a7619c53abd45c9f56439364fdcaf1dc354a988b4bc65a6bea0", + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "fb2caecc4c7ccfa9c64eb8e9e17a3906f1b49c20de07d3ff42ee0bafecc911b5", + "rustCaptureSha256": "fe3cbc20e57ec400e5e9d7ef956fbf500625c55ccc8f1e8437fb9c6dd72da2b3", + "comparisonSha256": "bbde4ddd563d480344ffab38baac8e20a2a090da7504e3a6fc92658672aa871b", + "contract": "ordinary-full-pool" + }, + { + "id": "compare-matrix-medium-60", + "chartId": 10000201, + "nativeFrames": 5500, + "coreComparisons": 1793000, + "differences": 0, + "maxFloatUlp": 0, + "fps": 60, + "actualOrigin": { + "5": 146, + "4": 109, + "3": 73, + "2": 36 + }, + "actualConverted": { + "5": 146, + "4": 109, + "3": 73, + "2": 36 + }, + "inputSequenceSha256": "23188e8b1df4024278b8dd9ce524e9e275f794b2dd9a2f6ffb2f1a94f0380186", + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "4b75f243a5c6a7c397fc7f077ae57c5150d3d826a1481d0722bf4a90f38bd25f", + "rustCaptureSha256": "7cf3fa9e0ee9b9e834758708b0814fef2faa6ac58877763d09b00e5e1c00de92", + "comparisonSha256": "749a236eef42a8119820e30f638de2040f91008e20b43ea614ca3bf2a9439b9d", + "contract": "ordinary-full-pool" + }, + { + "id": "compare-matrix-offsets-60", + "chartId": 10000201, + "nativeFrames": 5500, + "coreComparisons": 1793000, + "differences": 0, + "maxFloatUlp": 0, + "fps": 60, + "actualOrigin": { + "5": 274, + "3": 32, + "4": 58 + }, + "actualConverted": { + "5": 274, + "3": 32, + "4": 58 + }, + "inputSequenceSha256": "02a6e0aa9d3aebe1c2eadd36f1b416eb3bfa7cf3e05dbf698737a860f242adf0", + "requestedFieldCoverageComplete": false, + "nativeCaptureSha256": "50006439449898873709e2d54a16dbc02ebfbb918e75ded54544bd6ff3b93356", + "rustCaptureSha256": "9b332ca2ea50bec6626bbfe20e66c7cc43e9c71d7105a2d3b6bd29209b57bba8", + "comparisonSha256": "fc33096f901038ea9e56288a9a90f807e5b7bfa8b7ffc9bd86f7347217695d10", + "contract": "ordinary-full-pool" + } + ], + "scope": "ordinary full skills and prior solo Gekisou scoring lane; event/grade stream consumed, not independent touch replay", + "core": "Declared existing state and stage observations only, by contract; no derived elapsed-time mirror.", + "limitations": [ + "all modes and scenarios not completed", + "no independent touch replay", + "native-only elapsed/countDecrease not compared", + "live maxCombo / same-definition judgedCount / same-definition settledScore not completed in this ordinary lane", + "life zero offline stepping is not a game failure/continue lifecycle proof", + "IFix online patches and server rewards not covered", + "pool identities/stay state covered; overlapping repeated member triggers not exhausted", + "support and card Gekisou full chains not completed" + ], + "sources": [ + "private evidence manifest and semantic audit retained locally; raw inputs not shipped" + ], + "productionRepairs": [ + { + "name": "unscored operation table gate", + "scope": "preserve life/combo/callbacks; skip score command only", + "regressions": 2 + }, + { + "name": "network rank snapshots", + "scope": "native NetworkRankingUpdater no exact-timestamp rewind; separate ranked solo adapter preserves statistics definition", + "regressions": 2 + } + ], + "counts": { + "frames": 104250, + "coreFieldComparisons": 25605000, + "rows": 17 + }, + "countQualification": "Field comparisons include identities and inactive pool states; these are checks, not independent samples or a correctness probability.", + "lifeThresholdSample": { + "frames": 5500, + "effectStates": 45, + "frame706": { + "life": 700, + "effect35": "ExecuteFrame", + "effect30": "Stay" + }, + "frame742": { + "life": 600, + "effect35": "Executing", + "effect30": "Stay" + }, + "coreComparisons": 2288000, + "differences": 0, + "maxUlp": 0 + }, + "rankEstimateContract": "counterfactual solo exact-start/end score queries with fixed rank bonuses; opponents not simulated; native network instead uses controller frame snapshots", + "factorExtremeSample": { + "frames": 5500, + "effectStates": 30, + "coreComparisons": 1545500, + "differences": 0, + "maxUlp": 0, + "deck": [ + [ + 3, + 5 + ], + [ + 5, + 5 + ], + [ + 1, + 5 + ], + [ + 2, + 5 + ], + [ + 7, + 5 + ] + ], + "frame706NoteScoreUpBits": "4013330a", + "frame706NoteScoreUp": 2.299990177154541 + }, + "contracts": { + "ordinary-full-pool": { + "integerFields": [ + "frame", + "timeMs", + "score", + "life", + "combo" + ], + "skillFields": [ + "index", + "poolIndex", + "effectId", + "skillState", + "state", + "executeMs", + "finishMs", + "cumulativeCount" + ], + "float32BitFields": [ + "comboScoreUp", + "noteScoreUp", + "just", + "perfect", + "great", + "good", + "skills.extendedBits" + ], + "qualification": "All existing pool effect states read directly; identity checks and inactive states included. Native elapsed/countDecrease remain unmodeled." + }, + "ordinary-first-pool": { + "integerFields": [ + "frame", + "timeMs", + "score", + "life", + "combo" + ], + "skillFields": [ + "index", + "skillState", + "state", + "executeMs", + "finishMs", + "cumulativeCount" + ], + "float32BitFields": [ + "comboScoreUp", + "noteScoreUp", + "just", + "perfect", + "great", + "good", + "skills.extendedBits" + ], + "qualification": "One first pool state per member; remaining pools not compared in these earlier two runs." + }, + "solo-gekisou": { + "integerFields": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "settledScore", + "randomDraws" + ], + "rangeFields": [ + "state", + "combo", + "maxCombo", + "justCount", + "startScore", + "endScore", + "luckPoints", + "luckGauge", + "rushCombo" + ], + "float32BitFields": [], + "qualification": "score compares native FrameResult cache with an added Rust stage observer copying the existing calculator value before ranking queries; settledScore compares native calculator after ranking with unchanged Rust score(). No reconstructed elapsed counter. Global live maxCombo, judgedCount, range rawJust/judgedCount and complete float fields remain missing." + } + }, + "floatQualification": "maxFloatUlp applies only to the bit fields actually compared per contract; integer-only Gekisou rows do not certify full floats." +} \ No newline at end of file diff --git a/docs/validation/native-longcharts-2026-09-30.json b/docs/validation/native-longcharts-2026-09-30.json new file mode 100644 index 0000000..e1f62e8 --- /dev/null +++ b/docs/validation/native-longcharts-2026-09-30.json @@ -0,0 +1,559 @@ +{ + "schema": "ournotes.longcharts-public-validation/1", + "date": "2026-09-30", + "qualification": "指定受测判定流、帧序及有限状态字段上的逐帧相等证据;技能池观测由 Rust producer 按明确池身份规范化,不额外证明 Rust 内部容器或效果应用顺序等价。字段次数不是独立实验数或全游戏等价证明。", + "nativeVsRust": { + "actualCaseCount": 7, + "distinctChartCount": 4, + "totalFrames": 70747, + "totalScalarChecks": 19971785, + "differenceCount": 0, + "maxDifferenceUlp": 0, + "poolCountEachFrame": 30, + "cases": [ + { + "caseId": "native-long-10001503-mixed-30", + "chartId": 10001503, + "fps": 30, + "scenario": "mixed", + "frameCount": 5215, + "scalarChecks": 1474393, + "poolCount": 30, + "status": "passed", + "differenceCount": 0, + "maxDifferenceUlp": 0, + "originJudgementCounts": { + "Perfect": 893, + "Great": 353, + "Good": 178, + "Bad": 177, + "Miss": 176, + "Just": 0, + "Pass": 97, + "None": 0 + }, + "convertedJudgementCounts": { + "Perfect": 893, + "Great": 353, + "Good": 178, + "Bad": 177, + "Miss": 176, + "Just": 0, + "Pass": 97, + "None": 0 + }, + "judgementCount": 1874, + "skillEventCount": 5, + "firstLifeZero": { + "frameIndex": 342, + "frame": 342, + "timeMs": 11400, + "life": 0 + }, + "firstVisibleComboReset": { + "frameIndex": 95, + "frame": 95, + "timeMs": 3166, + "previousCombo": 5, + "combo": 0 + }, + "multipleJudgementFrameCount": 487, + "multipleSkillEventFrameCount": 0, + "observedTailFrameCount": 60, + "observedFinishEpisodeCount": 5, + "observedTimestampIntervalsAfterLastJudgement": 0, + "sha256": { + "nativeCapture": "af4e489ffd9e12c0c846471a3aa4334b831013a5677ae4ec59c06ea91aee2317", + "rustCapture": "2a07375bcd6e9ab2b6e7f73fe8645b56fcd8b02cdf58edf15e5e701ca5be5fe7", + "contract": "006de4887bbc560b365d8c4c467fa15b6b44b71d517bef491f01e89d405bf0e5", + "comparison": "41f4ce91b0ba0912a5951f5e1affebce9fcd3d215bef538f799302f9fc999e9b" + } + }, + { + "caseId": "native-long-10001503-mixed-60", + "chartId": 10001503, + "fps": 60, + "scenario": "mixed", + "frameCount": 10429, + "scalarChecks": 2944741, + "poolCount": 30, + "status": "passed", + "differenceCount": 0, + "maxDifferenceUlp": 0, + "originJudgementCounts": { + "Perfect": 893, + "Great": 353, + "Good": 178, + "Bad": 177, + "Miss": 176, + "Just": 0, + "Pass": 97, + "None": 0 + }, + "convertedJudgementCounts": { + "Perfect": 893, + "Great": 353, + "Good": 178, + "Bad": 177, + "Miss": 176, + "Just": 0, + "Pass": 97, + "None": 0 + }, + "judgementCount": 1874, + "skillEventCount": 5, + "firstLifeZero": { + "frameIndex": 683, + "frame": 683, + "timeMs": 11383, + "life": 0 + }, + "firstVisibleComboReset": { + "frameIndex": 190, + "frame": 190, + "timeMs": 3166, + "previousCombo": 5, + "combo": 0 + }, + "multipleJudgementFrameCount": 471, + "multipleSkillEventFrameCount": 0, + "observedTailFrameCount": 120, + "observedFinishEpisodeCount": 5, + "observedTimestampIntervalsAfterLastJudgement": 0, + "sha256": { + "nativeCapture": "4993c68f375791f0ccbb44ed1e93df3a1feaf993d9f30a12bfbb72650e7d2258", + "rustCapture": "afc4cd458c4320f3f80e4ffed27d4197afd1e29b2a3d368787eec692894530cc", + "contract": "3b4faeb9dd9778967dceab06ed844d45e1a27fd07a1256d57651590e16908b13", + "comparison": "0235a3d48609472f2177ec56428e5ae2b832238d3c82b226c65574bb074c4072" + } + }, + { + "caseId": "native-long-10001503-mixed-120", + "chartId": 10001503, + "fps": 120, + "scenario": "mixed", + "frameCount": 20856, + "scalarChecks": 5885155, + "poolCount": 30, + "status": "passed", + "differenceCount": 0, + "maxDifferenceUlp": 0, + "originJudgementCounts": { + "Perfect": 893, + "Great": 353, + "Good": 178, + "Bad": 177, + "Miss": 176, + "Just": 0, + "Pass": 97, + "None": 0 + }, + "convertedJudgementCounts": { + "Perfect": 893, + "Great": 353, + "Good": 178, + "Bad": 177, + "Miss": 176, + "Just": 0, + "Pass": 97, + "None": 0 + }, + "judgementCount": 1874, + "skillEventCount": 5, + "firstLifeZero": { + "frameIndex": 1365, + "frame": 1365, + "timeMs": 11375, + "life": 0 + }, + "firstVisibleComboReset": { + "frameIndex": 379, + "frame": 379, + "timeMs": 3158, + "previousCombo": 5, + "combo": 0 + }, + "multipleJudgementFrameCount": 463, + "multipleSkillEventFrameCount": 0, + "observedTailFrameCount": 240, + "observedFinishEpisodeCount": 5, + "observedTimestampIntervalsAfterLastJudgement": 0, + "sha256": { + "nativeCapture": "95b87c83849b9e0c9cb5b88ed8b54f4c27136b4a4e86ae69786509f6372ba659", + "rustCapture": "dbfb3e6535d67056b8e8fdc75bd7c32b1ab128936d533648655492efd10fc6d7", + "contract": "ce10249b0d0337429d1e6d7f25736033e86beff3688631e2e07f4077f59c84b1", + "comparison": "a1ae01408274a933e72b6397a6ac2526f3df07811089b6eca89c323494088a41" + } + }, + { + "caseId": "native-long-10001503-low-60", + "chartId": 10001503, + "fps": 60, + "scenario": "low", + "frameCount": 10429, + "scalarChecks": 2944741, + "poolCount": 30, + "status": "passed", + "differenceCount": 0, + "maxDifferenceUlp": 0, + "originJudgementCounts": { + "Perfect": 175, + "Great": 357, + "Good": 359, + "Bad": 355, + "Miss": 531, + "Just": 0, + "Pass": 97, + "None": 0 + }, + "convertedJudgementCounts": { + "Perfect": 175, + "Great": 357, + "Good": 359, + "Bad": 355, + "Miss": 531, + "Just": 0, + "Pass": 97, + "None": 0 + }, + "judgementCount": 1874, + "skillEventCount": 5, + "firstLifeZero": { + "frameIndex": 351, + "frame": 351, + "timeMs": 5850, + "life": 0 + }, + "firstVisibleComboReset": { + "frameIndex": 171, + "frame": 171, + "timeMs": 2850, + "previousCombo": 3, + "combo": 0 + }, + "multipleJudgementFrameCount": 471, + "multipleSkillEventFrameCount": 0, + "observedTailFrameCount": 120, + "observedFinishEpisodeCount": 5, + "observedTimestampIntervalsAfterLastJudgement": 0, + "sha256": { + "nativeCapture": "58b3d55e67c1ba53d1e7421fb8581db9791494440c269d220caa0436304161f0", + "rustCapture": "1e6da17cad09ff888cfc801bb6a681fcc7dfc88ae59d93ab4e1c2c59b5e02a0f", + "contract": "3b4faeb9dd9778967dceab06ed844d45e1a27fd07a1256d57651590e16908b13", + "comparison": "8e4cc4d128e72381a039e4c537f1599e00364d531820a058006b152763a88ef1" + } + }, + { + "caseId": "native-long-10003402-mixed-60", + "chartId": 10003402, + "fps": 60, + "scenario": "mixed", + "frameCount": 9405, + "scalarChecks": 2654109, + "poolCount": 30, + "status": "passed", + "differenceCount": 0, + "maxDifferenceUlp": 0, + "originJudgementCounts": { + "Perfect": 408, + "Great": 162, + "Good": 82, + "Bad": 83, + "Miss": 83, + "Just": 0, + "Pass": 124, + "None": 0 + }, + "convertedJudgementCounts": { + "Perfect": 408, + "Great": 162, + "Good": 82, + "Bad": 83, + "Miss": 83, + "Just": 0, + "Pass": 124, + "None": 0 + }, + "judgementCount": 942, + "skillEventCount": 5, + "firstLifeZero": { + "frameIndex": 1097, + "frame": 1097, + "timeMs": 18283, + "life": 0 + }, + "firstVisibleComboReset": { + "frameIndex": 201, + "frame": 201, + "timeMs": 3350, + "previousCombo": 6, + "combo": 0 + }, + "multipleJudgementFrameCount": 143, + "multipleSkillEventFrameCount": 0, + "observedTailFrameCount": 120, + "observedFinishEpisodeCount": 5, + "observedTimestampIntervalsAfterLastJudgement": 0, + "sha256": { + "nativeCapture": "3ec2403ac876dba1154c60e2d68b0fa1ba606c22c8269429621fd592f1d2f8a1", + "rustCapture": "ee01d80657048a58df1d6f2977a21996619d0f7672bd826e3de244a3778b9088", + "contract": "129946f7ca8deadc47972d1a70c9ada6cd6c4abf9fdbf5eb33ffecc78ccacb44", + "comparison": "636b7a7c01219d1d76ad1408be154cbfe118fc6142e18eb57d7a5578e9c4199b" + } + }, + { + "caseId": "native-long-10005603-tail-boundary-60", + "chartId": 10005603, + "fps": 60, + "scenario": "tail-boundary", + "frameCount": 5606, + "scalarChecks": 1582849, + "poolCount": 30, + "status": "passed", + "differenceCount": 0, + "maxDifferenceUlp": 0, + "originJudgementCounts": { + "Perfect": 420, + "Great": 165, + "Good": 89, + "Bad": 85, + "Miss": 84, + "Just": 0, + "Pass": 128, + "None": 0 + }, + "convertedJudgementCounts": { + "Perfect": 420, + "Great": 165, + "Good": 89, + "Bad": 85, + "Miss": 84, + "Just": 0, + "Pass": 128, + "None": 0 + }, + "judgementCount": 971, + "skillEventCount": 5, + "firstLifeZero": { + "frameIndex": 641, + "frame": 641, + "timeMs": 10683, + "life": 0 + }, + "firstVisibleComboReset": { + "frameIndex": 180, + "frame": 180, + "timeMs": 3000, + "previousCombo": 4, + "combo": 0 + }, + "multipleJudgementFrameCount": 305, + "multipleSkillEventFrameCount": 0, + "observedTailFrameCount": 111, + "observedFinishEpisodeCount": 5, + "observedTimestampIntervalsAfterLastJudgement": 0, + "sha256": { + "nativeCapture": "b3bf7fa80e42d3b81d9a97e2f68280358b875ec380fb41ca717741065e295cd8", + "rustCapture": "3238033b89381deeee898ac3df24b3f4ef735284efd8f1551530af93831e0cdd", + "contract": "1caa1db3b8c0ef2359f30086cd346c4b60981404a3557809ad236eaad4815055", + "comparison": "74828f7e39a25cb553f41d8aff0260fc620738345df99c07d6445d0d6855db1b" + } + }, + { + "caseId": "native-long-10010703-mixed-60", + "chartId": 10010703, + "fps": 60, + "scenario": "mixed", + "frameCount": 8807, + "scalarChecks": 2485797, + "poolCount": 30, + "status": "passed", + "differenceCount": 0, + "maxDifferenceUlp": 0, + "originJudgementCounts": { + "Perfect": 483, + "Great": 187, + "Good": 103, + "Bad": 90, + "Miss": 97, + "Just": 0, + "Pass": 144, + "None": 0 + }, + "convertedJudgementCounts": { + "Perfect": 483, + "Great": 187, + "Good": 103, + "Bad": 90, + "Miss": 97, + "Just": 0, + "Pass": 144, + "None": 0 + }, + "judgementCount": 1104, + "skillEventCount": 5, + "firstLifeZero": { + "frameIndex": 893, + "frame": 893, + "timeMs": 14883, + "life": 0 + }, + "firstVisibleComboReset": { + "frameIndex": 286, + "frame": 286, + "timeMs": 4766, + "previousCombo": 5, + "combo": 0 + }, + "multipleJudgementFrameCount": 299, + "multipleSkillEventFrameCount": 0, + "observedTailFrameCount": 120, + "observedFinishEpisodeCount": 5, + "observedTimestampIntervalsAfterLastJudgement": 0, + "sha256": { + "nativeCapture": "86fe7a39ad00f5fb0318c0b513d000fb5ba91f3c98348918088b35a00c4ad292", + "rustCapture": "b5a7559bbdcc7884997e0bc112f19900a13d5b76b29db56cbb24eaa81d5bb5b8", + "contract": "75689013027ca9288f2f4171955ef9af5f49ab4e64b929cd989fdd3af0ee6ae6", + "comparison": "caecd4e774195b35bb59db761e60d0ceb2e9228e01dca5c944e29183acc870cb" + } + } + ] + }, + "fieldContract": { + "frameIntegerFields": [ + "frame", + "timeMs", + "score", + "life", + "combo" + ], + "frameFloat32BitFields": [ + "deltaTimeBits" + ], + "factorFloat32BitFields": [ + "float32Bits.comboScoreUp", + "float32Bits.noteScoreUp", + "float32Bits.just", + "float32Bits.perfect", + "float32Bits.great", + "float32Bits.good" + ], + "orderedSkillIntegerFields": [ + "skills.*.index", + "skills.*.poolIndex", + "skills.*.effectId", + "skills.*.skillState", + "skills.*.state", + "skills.*.executeMs", + "skills.*.finishMs", + "skills.*.cumulativeCount" + ], + "orderedSkillFloat32BitFields": [ + "skills.*.extendedBits" + ], + "orderedSkillEventIntegerFields": [ + "skillEvents.*.index", + "skillEvents.*.timeMs", + "skillEvents.*.frame" + ], + "orderedConvertedJudgementIntegerFields": [ + "convertedFrameJudgements.*.noteId", + "convertedFrameJudgements.*.judgement" + ], + "nativeOriginCollectionRequired": true, + "nativeOriginCollectionChecks": [ + "ordered length equals converted", + "ordered noteId equals converted" + ], + "timeRules": "timeMs >= 0 and strictly increasing; deltaTime finite and >= 0", + "skillTimestampRules": "executeMs and finishMs >= -1; -1 is an allowed sentinel", + "poolOrder": "Comparator compares the supplied serialized array ordinal without sorting or matching by identity; Rust serializes skills after canonicalizing by (index, poolIndex, effectId), so this is not an internal-container-order comparison." + }, + "inputLevel": "controlled-native-auto-input; Rust replays native judgement records", + "scope": [ + "原生 ARM64 执行受测普通技能的构造、SkillExecutor 真实触发和成员路由、阶段更新、技能效果应用及帧后计分状态。", + "按原观测顺序核对 converted 判定及技能事件;Rust 技能池观测在序列化前按 (index,poolIndex,effectId) 规范化,所有 30 个池身份对应的九字段(含 inactive)参与比较。", + "比较器对收到的序列化数组按 ordinal 检查,本身不排序或按 ID 重配对;帧与整数逐项相等,deltaTime、六个计分因子与 extended 按 float32 原始位相等,不移帧、不容差。", + "原生 origin 判定保存为采集诊断并检查数量和 noteId 顺序;它不是独立 Rust 输入分类器验证。" + ], + "oracleSubstitutions": [ + "原生端由可控 auto-input 提供逐音符判定配置及命中时间;Rust 输入来自原生实际判定记录。", + "离线运行包含 icall/表现依赖替身、显示文本 ID 替身及观察钩子;没有真实 Unity 客户端会话或服务器。", + "此构造中 SkillInput 的 progress、currentFrameCombo 为 0,isLiveFinished 为 false;相关全条件与结束状态不能由本样本推广。" + ], + "excludedFromScalarComparisons": [ + "native origin classification independence", + "native elapsedBits", + "native countDecrease", + "judgedCount", + "soloScore", + "intermediate scoreStages", + "diagnostic boundary summaries" + ], + "pendingOrNotClaimed": [ + "同帧多个技能事件:七组实际观测次数均为 0。", + "最后判定后仍未结束的技能:七组已观测时间戳区间计数均为 0,尚无此边界覆盖证明。", + "真实触摸判定窗口、全部技能与非线性组合、完整暂停/重试/失败/结束生命周期及服务器逻辑。", + "life=0 后继续观测计分状态相等,不证明失败/退出生命周期。", + "技能池内部效果容器/应用顺序等价:Rust producer 已规范化池观测数组,且没有独立保存排序前观测,本比较不能额外证明内部顺序。" + ], + "comparatorValidation": { + "testMethods": 33, + "recordedSyntheticSubcases": 64, + "failures": 0, + "errors": 0, + "successful": true, + "qualification": "合成区分性测试,非额外原生样本;覆盖缺项/空池、共同截尾、事件/判定/池顺序、inactive 时间戳及 1 ULP 等拒绝条件。" + }, + "sourceIdentity": { + "productionSourceSnapshotSha256": "6ea8a51dfb6e8b9702229a3fd2b3d64ed6689b885581f3512fb7a3158c6f0cd5", + "snapshotFileCount": 55, + "stableBeforeAfterAndCopyRecorded": true, + "sourceSnapshotIncludesUncommittedProductionChanges": true, + "comparatorSha256": "f487869ef968486261991aa1a7fd29c20616cb9c09b76825c0a4b011e26ad0a5", + "testModuleSha256": "ae3c57cf126e4cbbca99af74da3bc48533fb6483ef85e5f4ae159e7bdf087240", + "arm64ExecutableSha256": "5a79dd5d9c479ed5a636193394f040f21a1f7b070d0869095e1af63b3f84ef88", + "qualification": "冻结生产源快照仅标识这七例来源,不是最新发布版本的全源身份;来源含复制时未提交修改,不能用 Git HEAD 替代,亦不等同于全部探针/诊断驱动总哈希。" + }, + "portability": { + "kind": "native-arm64-windows-to-linux-portability-rerun", + "chartId": 10001503, + "fps": 30, + "scenario": "mixed", + "frameCount": 5215, + "scalarChecks": 1474393, + "status": "passed", + "differenceCount": 0, + "maxDifferenceUlp": 0, + "countsAsAdditionalNativeVsRustExperiment": false, + "remoteInputFilesShaVerified": true, + "sha256": { + "linuxNativeCapture": "9aedf7092f1701a8a44b8f5ac80185bd812aa8cea68b9d4c4167febcb7da39d7", + "windowsNativeCapture": "af4e489ffd9e12c0c846471a3aa4334b831013a5677ae4ec59c06ea91aee2317", + "contract": "006de4887bbc560b365d8c4c467fa15b6b44b71d517bef491f01e89d405bf0e5", + "comparison": "66a7d3f184db12300c2837d1644097538cc2a42e7100965901979590655aec69", + "arm64Executable": "5a79dd5d9c479ed5a636193394f040f21a1f7b070d0869095e1af63b3f84ef88" + }, + "qualification": "同一 ARM64 机器码在 Windows 与 Linux 离线原生仿真之间的移植重跑;复用了已有 Windows 原生样本,不是第八组原生与 Rust 样本。", + "localLinuxNativeCaptureShaVerified": true + }, + "publicationBoundary": "仅发布聚合状态字段、案例标识与实际 SHA;不含原始谱面、逐音符输入、master 内容、二进制或私有绝对路径。", + "skillObservationOrdering": { + "canonicalizationProducer": "Rust diagnostic serializer, before JSON capture serialization", + "canonicalizationKeys": [ + "index", + "poolIndex", + "effectId" + ], + "identityMapping": { + "index": "member", + "poolIndex": "live pool instance index", + "effectId": "effect row ID" + }, + "getterEnumeration": "live.iter() then each live entry effects.iter()", + "comparatorOrdering": "supplied serialized array ordinal; comparator does not sort or match by identity", + "engineStateMutation": "sorting is applied to a separate JSON observation Vec after the read-only getter; it does not sort the model itself", + "skillEventsAndConvertedJudgements": "original observed order preserved; driver maps and collects these without sorting", + "limitation": "未保存并比较排序前技能数组,不能断言规范化在七例中天然不改变顺序。此字段证据验证明确池身份对应的状态,不额外证明 Rust 内部效果容器/应用顺序与原生等价。" + } +} diff --git a/docs/validation/native-modes-2026-09-30.json b/docs/validation/native-modes-2026-09-30.json new file mode 100644 index 0000000..55cd3cd --- /dev/null +++ b/docs/validation/native-modes-2026-09-30.json @@ -0,0 +1,333 @@ +{ + "format": "bdon-native-validation-public-summary/1", + "binary": { + "module": "original ARM64 libil2cpp.so, loader-restored", + "sha256": "5a79dd5d9c479ed5a636193394f040f21a1f7b070d0869095e1af63b3f84ef88", + "metadataDecodedSha256": "91aa665bd138276af998b9090f9395ada45ed78bbbc1e51cbb13ed2e61e9ef59" + }, + "basis": { + "version": "1.0.1-25", + "masterDataset": "TW 0b21c9f4", + "masterFileCount": 240, + "masterHashListSha256": "bfd2efc919942395f053b9c1c0a12234eb0898457e6526710415f192d021c9e5", + "hashVerifiedFunctionRecordCount": 110, + "functionCountMeaning": "Reviewed hash-verified function records; not a count of observed executions." + }, + "comparisonRules": { + "integerTolerance": 0, + "float32BitsTolerance": 0, + "frameShift": 0, + "noInputResultDeduplication": true, + "allDifferencesPreserved": true + }, + "playingStateCases": [ + { + "name": "Challenge / None", + "status": "pass for the declared PlayingState comparison", + "frames": 5502, + "coreComparisons": 1298487, + "derivedRankingComparisons": 0, + "comparedFields": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "skills", + "skillEvents", + "float32Bits" + ], + "actualJudgements": { + "3": 57, + "2": 76, + "1": 60, + "5": 122, + "4": 49 + }, + "lifeZeroFirstFrame": 618, + "comboBreaks": 15, + "finalScore": 164527, + "nativeCaptureSha256": "b6187a1b89935c8194c86faf43027c256d37d75fa196a2202a6e62e6ad271d3c", + "rustCaptureSha256": "e9229ce73b762a0133724c68941a1508cdbd742b398ba84ec460e09febdeafee", + "comparisonSha256": "59aa5f390604557cb868b84af31f5b043433fdc08a9ee0c9cf81b39a10c6887c", + "nativeStages": [ + "PlayingState.Enter (original virtual OnEnter dispatch)", + "PlayingState.Update", + "original FSM transition request", + "PlayingState.Exit and context disposal" + ], + "nextNativeState": "App.Live.SoloLiveFinishDirectionStateNode", + "nextNativeStateFrame": 5501, + "fieldLimits": [ + "full application FinishDirection/Result screens and server", + "real hardware audio/device/touch and renderer latency", + "card/GK/member condition skills", + "global maximum combo and judged/perfectContinue model fields (native saved, model has no corresponding public observer)", + "elapsed-time/counter diagnostic mirrors beyond existing updater state" + ] + }, + { + "name": "Challenge / FullCombo assist", + "status": "pass for the declared PlayingState comparison", + "frames": 213, + "coreComparisons": 50268, + "derivedRankingComparisons": 0, + "comparedFields": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "skills", + "skillEvents", + "float32Bits" + ], + "actualJudgements": { + "3": 1, + "2": 1 + }, + "lifeZeroFirstFrame": null, + "comboBreaks": 1, + "finalScore": 2018, + "nativeCaptureSha256": "cf03874836956eb946b6c1793c7bceaa874e83c0303f3f9159f140b46da65f56", + "rustCaptureSha256": "aa0dfbff7cfd9222fa0b5fd30cdb43110f6c6abbd0cc4e8badbb1295fdc4746e", + "comparisonSha256": "d26dcef1735ee8ccb90be08f3b4e5bceec71d88b26b75617b63bd1024223902c", + "nativeStages": [ + "PlayingState.Enter (original virtual OnEnter dispatch)", + "PlayingState.Update", + "original FSM transition request", + "PlayingState.Exit and context disposal" + ], + "nextNativeState": "App.Live.SoloLiveRetryStateNode", + "nextNativeStateFrame": 212, + "fieldLimits": [ + "full application FinishDirection/Result screens and server", + "real hardware audio/device/touch and renderer latency", + "card/GK/member condition skills", + "global maximum combo and judged/perfectContinue model fields (native saved, model has no corresponding public observer)", + "elapsed-time/counter diagnostic mirrors beyond existing updater state" + ] + }, + { + "name": "Challenge / AllPerfect assist", + "status": "pass for the declared PlayingState comparison", + "frames": 143, + "coreComparisons": 33748, + "derivedRankingComparisons": 0, + "comparedFields": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "skills", + "skillEvents", + "float32Bits" + ], + "actualJudgements": { + "3": 1 + }, + "lifeZeroFirstFrame": null, + "comboBreaks": 0, + "finalScore": 2018, + "nativeCaptureSha256": "4124587324b35a1984e41f0c76c39ec61fe3b5434058cf8380e2a02572004ccb", + "rustCaptureSha256": "4460b71acd64924ab88cc59c824b0f2a908a5c691ad77140aa23ee5c4cda8315", + "comparisonSha256": "a06445c5c923f7951bc0f2cdc31e6343bcc07cdc63a6e5e75987fd85e25810f0", + "nativeStages": [ + "PlayingState.Enter (original virtual OnEnter dispatch)", + "PlayingState.Update", + "original FSM transition request", + "PlayingState.Exit and context disposal" + ], + "nextNativeState": "App.Live.SoloLiveRetryStateNode", + "nextNativeStateFrame": 142, + "fieldLimits": [ + "full application FinishDirection/Result screens and server", + "real hardware audio/device/touch and renderer latency", + "card/GK/member condition skills", + "global maximum combo and judged/perfectContinue model fields (native saved, model has no corresponding public observer)", + "elapsed-time/counter diagnostic mirrors beyond existing updater state" + ] + }, + { + "name": "Mission / SoloGekisou", + "status": "pass for the declared PlayingState comparison", + "frames": 5502, + "coreComparisons": 1469049, + "derivedRankingComparisons": 22008, + "comparedFields": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "skills", + "skillEvents", + "float32Bits", + "settledScore", + "ranges", + "rawJusts" + ], + "actualJudgements": { + "3": 57, + "2": 76, + "1": 60, + "5": 110, + "4": 49, + "6": 12 + }, + "lifeZeroFirstFrame": 618, + "comboBreaks": 15, + "finalScore": 439015, + "nativeCaptureSha256": "37a1720ce35960aac9c7b44f507257cf291b5b8ae99cebe56a498bb0f0125ab3", + "rustCaptureSha256": "f118f0937408eba8d335dc1b846b8f863eb09bff2506e6dac50a2bc3db84437f", + "comparisonSha256": "9719206988bcf62c03ff48ab1cc17be77e7ef02cf90da710f46ae65e4dd03ac1", + "nativeStages": [ + "PlayingState.Enter (original virtual OnEnter dispatch)", + "PlayingState.Update", + "original FSM transition request", + "PlayingState.Exit and context disposal" + ], + "nextNativeState": "App.Live.SoloLiveFinishDirectionStateNode", + "nextNativeStateFrame": 5501, + "fieldLimits": [ + "full application FinishDirection/Result screens and server", + "real hardware audio/device/touch and renderer latency", + "card/GK/member condition skills", + "global maximum combo and judged/perfectContinue model fields (native saved, model has no corresponding public observer)", + "elapsed-time/counter diagnostic mirrors beyond existing updater state", + "native range judgedCount and accuracyAverageBits (saved, not modeled)" + ] + } + ], + "playingStateTotals": { + "cases": 4, + "frames": 11360, + "coreComparisons": 2851552, + "derivedRankingComparisons": 22008 + }, + "otherEvidence": { + "networkRanking": { + "cases": 572, + "comparisons": 15259, + "status": "pass", + "level": "native invocation and reused-calculator input stream, not whole-live frames", + "rawIntraTieOrder": "native raw order retained; Rust API has no intra-tie member-order contract, so it is not compared" + }, + "multiplayerIntegrationPerfect": { + "frames": 5500, + "coreComparisons": 187000, + "derivedRankingComparisons": 22000, + "status": "pass", + "ordinarySkills": "none", + "cardSkills": "none" + }, + "multiplayerIntegrationMixed": { + "frames": 19000, + "coreComparisons": 646000, + "derivedRankingComparisons": 76000, + "status": "pass", + "missions": [ + "Combo", + "Just", + "Luck" + ], + "ordinarySkills": "none", + "cardSkills": "none", + "actualGradeCoverage": [ + "Just", + "Perfect", + "Great", + "Good", + "Bad", + "Miss", + "Pass" + ], + "lifeZero": true, + "comboBreaks": true, + "packetDelayFrames": [ + 0, + 5, + 30 + ], + "rankingInput": "five fixed connected players, self index 2, tied leading players; offline packet rows" + } + }, + "bootstrap": { + "modes": [ + "free", + "free-gekisou", + "challenge", + "mission", + "battle", + "arena", + "tutorial" + ], + "status": "seven original mode-selection and music-score-creation branches executed natively", + "fullModeLifecycles": "Only the four scoped PlayingState cases above have a matching whole-chart/prefix field comparison.", + "arenaOverride": "Original native Arena branch selected a supplied real MasterLiveMusic override row; current ArenaMusic and ChallengeMusic catalogs contain zero rows." + }, + "scoreChannels": { + "frameCache": "Native LiveExecutorFrameResult.CurrentScore compared with existing Rust score state observed after skills and before Solo ranking rewind.", + "calculator": "Native current calculator result compared with existing production LiveModel.score().", + "observers": "Read-only observation getters in an isolated diagnostic worktree. No additional Rust scoring formula or native scoring HLE.", + "retainedPhaseMismatch": { + "firstFrame": 1176, + "timeMs": 19600, + "nativeFrameCache": 41910, + "nativeCalculatorAndRustScore": 40932, + "secondFrame": 2305, + "nativeFrameCacheSecond": 125224, + "nativeCalculatorAndRustScoreSecond": 123651, + "classification": "two same-frame observation channels; not a scoring-algorithm change", + "failedComparisonSha256": "10c4f73a0a7fdc5a6127b0b3d7f21e567a2c0dea5a9cd907940fcd427304bdbe" + } + }, + "nativeCallPoints": { + "playSettings": "0x6099d18", + "scoreCreation": "0x6124eb8", + "playingEnter": "0x6129bb0", + "playingUpdate": "0x612aee4", + "playingExit": "0x612ae80", + "challengeRetryDecision": "0x614ba68", + "gekisouControllerInitialization": "0x6125700", + "soloRankingRewind": "0x60a6c78", + "networkRankingUpdate": "0x60ac050" + }, + "substitutions": { + "shared": [ + "POSIX allocation/file/time environment", + "Unity JSON reflection used to supply master objects", + "audio length and clock", + "audio output and CRI callback registration", + "device delivers a native LiveSimulateInput", + "renderer consumes read-only live frames", + "localized display strings", + "Unity presentation color parser uses the original fallback branch" + ], + "finishAnimations": "zero-latency offline completion inputs; original native Action delegates and completion callbacks execute", + "missionOnly": [ + "UTC libc calendar functions for logging", + "fixed device identity and no-renderer graphics device", + "Unity visual-performer RNG only at two verified presentation callsites (0x6192370 and 0x61923d8); game luck/skill RNG stays native" + ] + }, + "missing": [ + "InitializeState coroutine and complete native deck/data-creator pipeline", + "FSM registration/dispatch and following retry/finish/result nodes", + "actual hardware device/touch windows and renderer/audio timing", + "server transport, authority, matchmaking, rewards and persistence", + "complete Battle/Arena/Tutorial PlayingState traces; those remain pending", + "card/GK/member conditional skills in these mode fixtures", + "global maximum combo, model judged/perfectContinue counters, range judgedCount and accuracy-average f32", + "native raw intra-tie ordering contract", + "real Challenge/Arena event catalog rows" + ], + "qualifications": [ + "These are scoped native-client algorithm and PlayingState lifecycle evidence, not proof of every scene, all devices or servers.", + "Challenge retry decisions are executed natively; Rust LiveModel has no lifecycle retry API, so no independent Rust retry-decision equivalence is claimed.", + "Challenge uses explicit native property setters for the value produced by the original option/settings path; the original Init coroutine is omitted.", + "Mission supplies actual MasterMemberCard and native Local.MemberCard constructor defaults; the skill container contains only five ordinary liveSkill1 entries, with card/GK/condition effects empty. Power-reading effects are not validated by this fixture.", + "Master material, binaries, captures, keys and decompilation remain private. This public summary contains only counters, declared fields, call points, hashes and limitations." + ] +} \ No newline at end of file diff --git a/docs/validation/native-source-2026-09-30.json b/docs/validation/native-source-2026-09-30.json new file mode 100644 index 0000000..39c27fc --- /dev/null +++ b/docs/validation/native-source-2026-09-30.json @@ -0,0 +1,61 @@ +{ + "format": "ournotes-deck.native-source-manifest/1", + "status": "inputs_recorded", + "clientVersion": "1.0.1-25", + "masterVersion": "0b21c9f4a3911370d5f981fb894c7544", + "region": "tw", + "modelCommit": "155bd2ad89d077d3bdcfbca9c45c0100475ab538", + "modelDirty": true, + "resources": [ + { + "role": "chart", + "bytes": 1277, + "sha256": "d52034d539e10651eeb896d10c0d9ef2e5f5e054d115a920e8d6b0d7bdd52c1d" + }, + { + "role": "contract", + "bytes": 601, + "sha256": "d6a31ca96361fce32142056522770ffbddc32fbc5e208168f70d493650760f26" + }, + { + "role": "modelSource", + "bytes": 81908, + "sha256": "e98694c7d7944eb66845a84f8862f2a565786a23ea7deadef746f61c6ce8c2f8" + }, + { + "role": "rustDriver", + "bytes": 5892, + "sha256": "02a5b4e18793070968cb00ce99a2203a844eeebf4aff48dd10a063eb9e36d260" + }, + { + "role": "originalLibraryElf", + "bytes": 214602464, + "sha256": "5a79dd5d9c479ed5a636193394f040f21a1f7b070d0869095e1af63b3f84ef88" + }, + { + "role": "relocatedMemoryImage", + "bytes": 217042944, + "sha256": "a62090474bc182d8f442b5463475ed8ab3e3840f9d01ae19303656d3441da1ce" + }, + { + "role": "imageMetadata", + "bytes": 152115, + "sha256": "b40626ee79928a8234bb575511fefc51684dddbc3851941b5a9cdf533ca57c58", + "recordedOriginalElfSha256": "5a79dd5d9c479ed5a636193394f040f21a1f7b070d0869095e1af63b3f84ef88" + }, + { + "role": "runtimeReadOnlySnapshotMetadata", + "bytes": 1671, + "sha256": "640066557d32cdadb9a90426c263f437c057682533a05198efda5446ce3c72b1", + "recordedRuntimeReadOnlyMemorySha256": "19473f4a680b8f835fb8d53242e1a52276ede3d4ecf9087c7885b4e8aefd10a8" + } + ], + "missingRoles": [], + "qualification": "Input identity only; no emulator execution or comparison performed.", + "derivation": { + "originalLibraryElf": "Loader-restored input ELF; SHA2565a79dd5... is the file identity before ELF relocation.", + "relocatedMemoryImage": "elfimg.py maps PT_LOAD virtual segments, performs RELATIVE/ABS64/GLOB_DAT/JUMP_SLOT relocations, zero fills memory and binds import stub addresses. Its a6209047... hash is not the ELF file hash.", + "imageMetadata": "Hash is JSON-file identity; embedded so_sha256 links it to the original ELF.", + "runtimeReadOnlySnapshotMetadata": "Hash is JSON-file identity; recordedRuntimeReadOnlyMemorySha25619473f4a... refers to the initialized in-memory readonly range[0x1000,0xbdf4000), verified by snapshot.load. It is neither the ELF nor whole memory-image hash." + } +} \ No newline at end of file diff --git a/docs/validation/native-touch-2026-09-30.md b/docs/validation/native-touch-2026-09-30.md new file mode 100644 index 0000000..e211a04 --- /dev/null +++ b/docs/validation/native-touch-2026-09-30.md @@ -0,0 +1,16 @@ +2026-09-30,客户端 1.0.1-25 离线原生检查:一份相同的原始 Touch 输入分别驱动真实 ARM64 输入/判定/计分链和 Rust 独立输入适配链,实际执行 1239 帧、50 个判定事件。帧内 629186 项整数或 float32 位模式检查全部相等;实际判定为 Perfect 16、Great 6、Good 6、Bad 6、Miss 16。重复捕获不增加独立输入样本数,本项只有 1 份 Touch 输入。 + +| 检查层 | 字段检查数 | 差异 | +|---|---:|---:| +| 同帧身份与时钟 | 2478 | 0 | +| 触摸提供器输出、flick、finger 与 f32 bits | 278775 | 0 | +| 判定分类、转换、时间/diff 与原始顺序 | 500 | 0 | +| FT 音符状态与 progress bits | 338760 | 0 | +| 长条状态 | 4956 | 0 | +| 同收尾阶段的分数、生命与连击 | 3717 | 0 | + +本样本覆盖 Tap 四个等级交界的早、迟边界值及两侧 ±1ms、实际 Great/Good/Bad/Miss、同帧多个判定及持按长条节点。音符状态以 ID 对齐,判定事件与 updater 身份保持原始顺序;分数、生命与连击在原生 PlayingState 收尾同步之后比较。逐音符区域资源适配与原生收尾适配属于私有验证扩展,不代表生产 RawScheduler 已发布完整物理触控能力。生产页的单局 replay 使用用户明确给出的完成判定。 + +Unity 设备记录、宿主时钟与 UI hit-test 仍由外部适配器提供。精确 ±1 帧投递、Trace 专用类型、动态窗口/Assist、真实设备投影及完整生命周期不在这一个样本的覆盖内;这些检查不构成全部触摸窗口等价证明。字段数包含重复与空状态,不是统计独立样本数。 + +源码身份:稳定 86 文件生产副本 aggregate SHA-256 `c8a3a4c107b22fd29500f74b3dfffa0a0b3af34e7c7bf052e8819ab856386afe`;含私有适配/驱动的实际对照副本 aggregate SHA-256 `50acc47733449fdbd0abd583167af76383486ffbb41b7dda9562e1f98c31ad84`。 diff --git a/docs/validation/probability-4011-2026-09-30.json b/docs/validation/probability-4011-2026-09-30.json new file mode 100644 index 0000000..129f0f8 --- /dev/null +++ b/docs/validation/probability-4011-2026-09-30.json @@ -0,0 +1,187 @@ +{ + "format": "ournotes-deck.native-probability-validation/1", + "date": "2026-09-30", + "status": "exact-in-declared-two-case-scope", + "client": "1.0.1-25 ARM64", + "masterRegion": "tw", + "masterVersion": "0b21c9f4a3911370d5f981fb894c7544", + "nativeBinarySha256": "5a79dd5d9c479ed5a636193394f040f21a1f7b070d0869095e1af63b3f84ef88", + "rustBaseCommit": "155bd2ad89d077d3bdcfbca9c45c0100475ab538", + "rustSourceUsesPrivateReadOnlyObservationHooks": true, + "sourceSha256": { + "nativeCaptureProgram": "41a6cb66975a35d2883c928d1edb1b201d856501cd0479ef35a1d3e00267a908", + "nativeCardSkillHarness": "6c908a66285a005099c7121c869779ef91280250e13764cbd90d07cc2a8ebc05", + "strictComparator": "68d6dc29b2090c118641c577d2929989622e9eb2dcbc11e61bd2e522c12a8097", + "rustTraceDriver": "7e0fafcafe98cbc8bed2797a95b24a8990d2df9f2bb63201b24b748260b856c1", + "rustProbabilityActualBranchObserver": "920fc54ff1804b38fb5b9f1373f61de4fa39d9336e9d51c73bcec66d11007096", + "rustRandomActualValueObserver": "26ae35146955f3fc4bcb96d69f469630831d98f6b683234570158988d05db390" + }, + "nativeFunctionEvidence": [ + { + "metadata_name": "App.LiveLogic.Skill.SkillConditionProbabilityExecuteChecker$$.ctor", + "file_va_hex": "0x55f5a44", + "c_sha256": "91c11cf4abc5062cc0fc759895105372253ea858e62c9ab949850f360d700192", + "hash_verified": true, + "status": "OK", + "abi_status": "APPLIED" + }, + { + "metadata_name": "App.LiveLogic.Skill.SkillConditionProbabilityExecuteChecker$$Check", + "file_va_hex": "0x55f6890", + "c_sha256": "cf2c5636cc2f969639ac2b1561e080c961c50183fa95cb8a3940e60db1e5b258", + "hash_verified": true, + "status": "OK", + "abi_status": "APPLIED" + }, + { + "metadata_name": "App.Live.LivePlayingSkillUtility.<>c$$b__0_1", + "file_va_hex": "0x61298e0", + "c_sha256": "fa0227a4d790f49c382644eac49e68602a422d60980bbebc9e5e1f9f16ffb5e3", + "hash_verified": true, + "status": "OK", + "abi_status": "APPLIED" + }, + { + "metadata_name": "App.LiveLogic.Skill.SkillEffectState$$.ctor", + "file_va_hex": "0x5618afc", + "c_sha256": "d1e0379bad5067358fdaf317046e89dabac0333a34cd13b887feef532d15dfb3", + "hash_verified": true, + "status": "OK", + "abi_status": "APPLIED" + } + ], + "masterDecodedSourceSha256": { + "MasterSkillCondition": "3b367b3870a46c732a103598aaf20e5f02ec0973bb1d9133fe13e50eec8c1556", + "MasterSkillConditionSet": "503591de2eece727b506aedef25e1faf5990a44b12c810a16be1d84a724dc07c", + "MasterGekisouSkillEffect": "7204762d794aecd0825b77df67d1e4b7b9a3dd81a2f738d254c8ec75fd7d5171", + "MasterGekisouSupportSkillEffect": "ffd03806a7e140a7a2284d727c061a545735a8cc558c19088effe7a10a65dff7" + }, + "bgm": { + "acbSha256": "374798095fcf5086bbfe83a9b2643019e103b0cdd58850c6c5b2f523f5b84a77", + "acbBytes": 3860416, + "length": { + "lengthMs": 120557, + "samples": 5786757, + "sampleRate": 48000, + "durationMs": 120557 + } + }, + "cases": [ + { + "id": "high-seed0", + "chartId": 10000303, + "fps": 60, + "frames": 7500, + "totalPower": 300000, + "musicLengthMs": 120557, + "cards": { + "testedCardId": 43, + "gkSkillId": 20, + "gkLevel": 5, + "gkSupportSkillId": 66, + "gkSupportLevel": 5, + "otherMembers": "four real card1 performers" + }, + "seed": 0, + "input": { + "policy": "All scored notes Perfect", + "actualOriginJudgements": { + "5": 1012, + "7": 55 + }, + "lifeMinimum": 1000 + }, + "skillPoolCount": 70, + "coreComparisons": 5340000, + "coreDifferences": 0, + "probabilityChecks": 9, + "probabilityOperandResultComparisons": 36, + "probabilityOperandResultDifferences": 0, + "checkerTrueCount": 4, + "finalNativeScore": 2835163, + "finalRustScore": 2835163, + "targetedRareBranch": null, + "sha256": { + "nativeCapture": "e43dbb30fd3412b870e6aaa215bbf2a378a52149000e100e81fdbb696f627c69", + "rustCapture": "85c3515c31cefa3dfa62273552cc8ef3510fb24304612455d56591a3a2a043c8", + "comparison": "acd23f6ef98e31e7ed08b001218af07147fa7b1cdd508e5546204ff5d5f9aef9", + "configuration": "e3651071db3fea3f6ee17c83024c68e7e3581d346fec7e3dfc9a2f60d628f394" + } + }, + { + "id": "life600-seed14", + "chartId": 10000303, + "fps": 60, + "frames": 7500, + "totalPower": 300000, + "musicLengthMs": 120557, + "cards": { + "testedCardId": 43, + "gkSkillId": 20, + "gkLevel": 1, + "gkSupportSkillId": 66, + "gkSupportLevel": 5, + "otherMembers": "four real card1 performers" + }, + "seed": 14, + "input": { + "policy": "Four earliest scored notes Miss, all remaining scored notes Perfect", + "actualOriginJudgements": { + "1": 4, + "5": 1008, + "7": 55 + }, + "lifeMinimum": 600 + }, + "skillPoolCount": 70, + "coreComparisons": 5340000, + "coreDifferences": 0, + "probabilityChecks": 9, + "probabilityOperandResultComparisons": 36, + "probabilityOperandResultDifferences": 0, + "checkerTrueCount": 4, + "finalNativeScore": 2760861, + "finalRustScore": 2760861, + "targetedRareBranch": { + "purpose": "cover a real 5 percent successful branch, not estimate a population probability", + "firstNativeValue": 0.040197670459747314, + "nativeRate": 0.05000000074505806, + "comparison": "strict less-than" + }, + "sha256": { + "nativeCapture": "cc5aa782dc3ef15b2c3bb240df0021edffa655b647cf028e395de78898ca75a9", + "rustCapture": "0b896789c7ce2630e078da63aedec5a87340c4aa0db71e374d5ba084d805b6a8", + "comparison": "06995e3173140ed6ff3a13dc8e9cea3ce69d00528fd3ffd37b2ef7fe0c891336", + "configuration": "cbefbc73e046a94a998a26ef61fe80d2ceb8d2f8a3f0b451426bbaf71602d503" + } + } + ], + "totalFrames": 15000, + "coreComparisons": 10680000, + "probabilityOperandResultComparisons": 72, + "coreDifferences": 0, + "probabilityOperandResultDifferences": 0, + "coreFieldGroups": [ + "same-frame pre-ranking frame score and post-ranking calculator score/life/live combo/random call count", + "70 effect pool identities/state/execute time/finish time/extended bits/cumulative count plus ordinary parent state", + "three ranges state/combo/max combo/Just/raw Just/start score/end score/luck points/gauge/rush combo", + "ordinary skill events and converted judgement ordering", + "six float32 factors before ranking and six after ranking" + ], + "checkerObservedFields": [ + "frame", + "rateBits", + "valueBits", + "passed" + ], + "scope": "Offline Unicorn executes native SkillStatus, SkillExecutor, real card/member routing and ordinary/Gekisou appliers, solo controller and ranking. Probability results are observed after native fcmp/cset, never replaced. Rust records actual branch operands/results without additional RNG draws.", + "qualifications": [ + "Only two deliberate branch-coverage inputs; not a game seed probability or expectation estimate.", + "Checker true does not imply final skill activation: remaining life/band AND conditions still apply.", + "This master has no ordinary LiveSkill/SupportSkill rows referencing4011; tested4011 is Gekisou.", + "Rust consumes native origin judgements, not independent raw touch replay.", + "Unity JSON reflection/presentation/platform substitutes remain; libunity/server/full failure-reward lifecycle not run.", + "Inactive pools included; conditional parent state, elapsed and countDecrease excluded.", + "These samples certify declared simulation fields; future production replay/raw-window revisions need their own integrated checks." + ] +} diff --git a/docs/validation/production-checks-2026-09-30.json b/docs/validation/production-checks-2026-09-30.json new file mode 100644 index 0000000..d100cad --- /dev/null +++ b/docs/validation/production-checks-2026-09-30.json @@ -0,0 +1,248 @@ +{ + "format": "ournotes-deck.production-checks/1", + "date": "2026-09-30", + "status": "pass", + "passedTests": 272, + "commandsAllExitZero": true, + "sourceStableDuringAllFinalChecks": true, + "commands": { + "suite-final2": [ + "cargo", + "test", + "--release", + "-j2" + ], + "clippy-final": [ + "cargo", + "clippy", + "--all-targets", + "-j2", + "--", + "-D", + "warnings" + ], + "fmt-final": [ + "cargo", + "fmt", + "--check" + ], + "msrv": [ + "cargo", + "check", + "--all-targets", + "-j2" + ] + }, + "logSha256": { + "suite-final2": "d109517fafda145697a405a51e56f7f804197deaee5fee26145bbcffc9e85b8a", + "clippy-final": "56e102eea9abdde25ce13a07a94e20635deb6cc826ffaf6a30166308a48d49ed", + "fmt-final": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "msrv": "40a455eea68938a923fb61936efed21848528001d281d04038a05929a0393d3a" + }, + "rustCompiler": "rustc 1.98.1 (48a229cea 2026-09-01)", + "minimumSupportedRustCheck": "rustc 1.88.0 (6b00bc388 2025-06-23), all targets", + "sourceTreeSha256": "9d647e0527f1bbd2c0bf832f7536161ebd0399413ff208d800a8cd62cf1f5830", + "sourceTreeSha256Algorithm": "SHA256 of Python json.dumps(filesSha256, sort_keys=True), using relative POSIX paths for src/**/*.rs, tests/**/*.rs, Cargo.toml and Cargo.lock, with default JSON separators.", + "sourceFileCount": 78, + "gitHeadDuringChecks": "155bd2ad89d077d3bdcfbca9c45c0100475ab538", + "workingTreeDirtyDuringChecks": true, + "sourceIdentityQualification": "The tested model identity is the frozen working-source fingerprint, not the old HEAD. A later final commit must bind to these checked sources; public docs and tooling were outside this source hash.", + "productionFilesSha256": { + "Cargo.lock": "886ac98b4401d2eaa26c82ad22369cb014f2c597e511c4d1c6b988b1b410452d", + "Cargo.toml": "a5df9e8d9f3860e3091ac0a9c9f036131afba0ad21512ba230c971df397606bb", + "src/bin/ournotes-deck.rs": "25294b9871eb70b4b8c2b65389e249f3603bbe88017862addf397c45f2a23a03", + "src/bonus.rs": "e2a7a0547c66891831b76649024e781f3747eba0261c07942bfb5863b8c4ea57", + "src/calc.rs": "832682e57e19adc25a468463e0f10b13d73510a82e1bbf300ad8e4233e3aa885", + "src/cards.rs": "b99fd7d3aa817347e37b2899368d6d34fc06645f0a84223041e222ef2e4e4ddf", + "src/chartstats/aptitude.rs": "c664b758d8e774ad86017c462741d654c6bb7d1ba0ee056a4fec529c52b0d588", + "src/chartstats.rs": "343186848f5d47c28cb0524329eda2d260d71ca4f8e1e3123eea57cf48da7d90", + "src/data.rs": "cbbbabff3ff0e011bdb47def4f4b33279fefcd39e1537dfc2570ba1cc2f9e389", + "src/deck.rs": "8ab6efa49b0da4ac3872b90ea4a41b74fb9fe6ca21de588d63664c10896d48d3", + "src/error.rs": "a91f945757661463cab64c09c1ae1a3e79fe9481fa225fd80bc8cad6f6cd29f4", + "src/event.rs": "909ad6ca4b4da70d7e2eac96e94c64de05d932fccb0b01e8722ce3b986285972", + "src/lib.rs": "b4da9cfa3870e6ddcd723dde10180958e6b1100f8fd88d9e03e6d9d08e1bcac4", + "src/live/full/combo.rs": "5f8c646978f2131e8fe420d5fb44dbab2f05b4fb0f45248eb9274accf400ecac", + "src/live/full/conditions.rs": "b587c539f3836a1ff52f2573a65be12675b62858eeb141cc89235093d1de005f", + "src/live/full/convert.rs": "0e343a9920d674406537c3a513028fecb3ca82c786414e1edb4319ad42ae2a8b", + "src/live/full/engine.rs": "6f68c89ec2ea0486f013f0a16d585192020c09c74106910128e2aca2775ab6f9", + "src/live/full/gaps_tests.rs": "7317ee1d282f462f956739ebb90acdcb481e56b24e11ec11767a974d00efee76", + "src/live/full/gekisou.rs": "7d469dadd44ee8b7cc28df26cdffd1a14930bf65ec02a7aaee85f32eac7c2799", + "src/live/full/life.rs": "de23dda1733fe0ca2c2c6b2f4cb04e4e3373ca1c74f18c7719345a66117330b4", + "src/live/full/mod.rs": "ce9369b7a8afa7257ad4aa2e990c82700bbb1cb8210f597937dd23247c54a544", + "src/live/full/pool_regression.rs": "9fabd6635fc2f5f38c8d790837339bb5313d2afda4bb7ab4ad56bea9f0cf3388", + "src/live/full/range_frames.rs": "530379ce632b738088310d53a679bb96707bd17b4139c6ec2635c9e0bc00f9fe", + "src/live/full/raw_runtime.rs": "008e7cad76cb2e871b8a53ac2eddcedfccebf4ce175efc2b59383d59cdeabe5c", + "src/live/full/scorecalc.rs": "aa587eb8ed139c8070e3c918a5d2e3c5e92014fbbf25005f875c7ed304d853b2", + "src/live/mod.rs": "05db7d177f46e086798a2a73231772eec5a2b66d47b0f62d9c54e2150962e136", + "src/live/model.rs": "08ff2a8018fd47badd22aacd65d5dc28a1cb66b3a4182547681597324060e551", + "src/live/random.rs": "ff2246732504b7de7aee0303e46db1c0df476e285bf06bbec11a34fec85f5734", + "src/live/raw.rs": "17ce3de5e4273a8b3580c5c37e92f3144fbc6d94995249bc4541b4059df9c3a6", + "src/live/raw_assist.rs": "0d9c090a2fef70f6ddb29be6fb20e650c8d2019b2cb7d82c4c22a37a661ddde4", + "src/live/raw_candidates.rs": "8649756e2b3f742268282a21dda700fa0974691a754da7158abb648a1641d5f4", + "src/live/raw_input.rs": "cabb0ef40bc0e583de2d62666b086f04ce2fb99569f8e455e46af8de345ce475", + "src/live/raw_just.rs": "3e69f65afbb9b29030d2b099bce311f5f3bdac35b70b293c639a88562fa63871", + "src/live/raw_scheduler.rs": "f0284275bc2f4af0e511b52c1e1335fdd9b88a9d2cdfa63f49d991c4642ddfd7", + "src/live/raw_updaters.rs": "6f3da592c16c2ebc06973b8cd2560b39e537118efe748d1f47c777adc16c7df2", + "src/live/raw_windows.rs": "1661fcc3651d6d0d69563e1294f3502c2f00bca369a73da62ffd4d2e3aefef35", + "src/live/score.rs": "aa5fe78354ee9acf800e3ea2ced2116687995f7f259a00b681d30b7f55b5b12b", + "src/live/seeds.rs": "6598a4336224bdb9493e4f020e97962530e0e46170d9619cb074ae2df88f245e", + "src/live/skill.rs": "6378793a86b87c27deee250d1bf0ace049f52c271e1b6aa5d358bd4948e86edf", + "src/live/skip.rs": "d28bd225aa86de911a41694c7e87d6644ca23c9031a57577711b081c2f577a66", + "src/master.rs": "ca5553b2c4f8c3a4ebcaeae5b4b91e2e7fbcc06c15b755a64e14a22adc882ff4", + "src/memory.rs": "ec6fbbd9ade10f3c119a66d3ecd4762d97207f4373eed0774edbc1215b101167", + "src/num.rs": "ef27242c5cebd987e223e03f2e10a257297a5634bed6929b6f3b001d31ca91a4", + "src/power.rs": "704ce1eca2cd86d9d06dbfeafe3fa5d00efaa5b27be76d1c423b73d748aa6bfc", + "src/replay.rs": "13d31815777ddce166d674f9525d0074428777c7ea24d8224cc84997ec3f5593", + "src/scenario.rs": "2383d78b302f85c91afe840d728454b717d402e384f51b5a17e7a2422ffa63cd", + "src/search/expectation.rs": "59323b7a58db9a4a755eba03fe66d5e90554fd2d54d87cb7f1da64f512182ee1", + "src/search/live.rs": "15f8fb7ec4a24df2df8771b8c4942fdc5f7d0df7cbf19b6566c2f25e2e2b2fc4", + "src/search/matching.rs": "d8f4664437191d3f577d1d3f17998d526a22e5d7295dc3dde49b95cead44f9bc", + "src/search/mod.rs": "904caef033cd47b5738f16bc8310c2843c278c99676b5a0b1fcb7ebec8a8b5ab", + "src/search/oracle.rs": "3b17372660aa0a2b0164b47e5bd7debf33fd733b883d0b22c5aea0c34ff38097", + "src/search/pool.rs": "dd8ff27bac2e4f61fd830a09311725b0e2dabf9af9d53c6caa46aebd3d797572", + "src/search/power.rs": "0387e12d64b3a8cceffee1ceb2f31b967e04ce1441b1319930b743b619d1e9ac", + "src/search/snaps.rs": "625884974b24b15cfef989f27c48e3d4681097713a422df3866f02c5cf505e3a", + "src/search/tables.rs": "133cf8adc7b8bad802c4a79c3d33bb46b2688d7ed8a9dc308d5eb538021cb273", + "src/search/topk.rs": "298e9a48c1c7b10ac26b81d3892387cb9383d4e6cedbeacb4aa85abf7cf7ba0a", + "tests/chartstats.rs": "cef1971ad1b037e3de891af60319e57d44bf9b365cee76b2a9d8ffe9527639fe", + "tests/common/mod.rs": "fa31350b5a52e812a779dae86a6a14f13ddb8542f7bb4bbfd3c70ae37c033d51", + "tests/data.rs": "c399c30f02391de73351e3426711818b639587f44b1f948689edb9989dd4e560", + "tests/event.rs": "489604c397582bfde3d7b0157e0d2b2cee8d27c0b5eb59974448fc4519f1c855", + "tests/expectation.rs": "fd988b80efeea06346d8ea4782823f9b84c08d0f66c6657600380d6c5a08568f", + "tests/gekisou.rs": "a232bc0df8c16248c57d30e5739b32069f00cd66c6d208a18c3646af5171480b", + "tests/gekisou_play.rs": "ee82f1ef3ad1070ff065db3f6a6b1247a9f1a2b98f76c8fbcee80e3607dd38aa", + "tests/livefull.rs": "c29bb5434ae82d3cdf8325a97b0c1aacadcfe4d8c65f319bf74369f15252ba6a", + "tests/model_gaps.rs": "91ea9c2b8bdb0e4c4ebb20d9c22660002189c31949c20323c6906a1b19597a3a", + "tests/power_consumers.rs": "7f758ecb0296df8da7c9df6ae239c5bf94a87101a6d7c22127677aa0fbe6cb87", + "tests/raw_assist_native.rs": "7234a0dc79df580df579e6ebe7167c295e27751fe29acccab12628ae4967340a", + "tests/raw_bridge.rs": "80a1bfa27d5a1f24e288f6f069726663ffc760f0f798b87f142e711e3ce41ca6", + "tests/raw_judgement.rs": "bf73953df3365390bdc167d7b6c996d99a769079b6bea936d50737c5d0075c32", + "tests/raw_just_fever.rs": "0f2e06f65afcc7ff492e585ae8d41babb3c5076d73cfd7d80a6035292c6312d0", + "tests/raw_scheduler.rs": "4aeacf5199a12fd949e15ec2be04d9a707b39119a2bc7ee644bef0ae7f1eb839", + "tests/raw_scheduler_native.rs": "e44e732661dc46c52f3e516fb5d20589d41bca0fc40ef8783f3983cdcf8e5eda", + "tests/raw_updaters.rs": "4a5e67b3e650cafabcc18200dda8fde2fae011557ca9989d202600e0c94debf5", + "tests/replay.rs": "68a033dd282f8f707563dd3b1d68a94edd211db4b53b8d56ceb3916a4471cca9", + "tests/scenario.rs": "36aa017aceeefad0120e02fbda937a99185f42b269b2b98ba88f5a2f42abe8ad", + "tests/scenario_search.rs": "4bae9160e69520884d6ef5f454bea98b8b2e2f8ce826c4bfd22dab04bb62979b", + "tests/search_oracle.rs": "6bb3419470da93e0cc2fe86650b2cbbb33ccb274d6d54309a7ecdcc9a2bc1b45", + "tests/search_snaps.rs": "d7de04dedee10d5af75b8d1ad3cc46bf189e9caf0eab494754bb4a12074fb88b" + }, + "cliExecutableSha256": "04ec71f255dd2c8cca34282758f5186970a5c9684742bafd9222cebbbae5200f", + "cliExecutablePlatform": "x86_64 Linux release binary built and exercised by the final production suite", + "behavioralRepairs": [ + { + "id": "unscored-operation-gate", + "category": "runtime-behavior", + "summaryZh": "未登记计分的操作先跳过计分类型转换;type122 / Pass 继续执行生命、连击和窗口回调。", + "regression": "unscored_pass_still_consumes_executor_window_callback" + }, + { + "id": "network-frozen-range-snapshots", + "category": "ranking-behavior", + "summaryZh": "真实 Network 模式保留区间控制器的起止分数快照,不走 Solo 时间回查。", + "regression": "external_completion_keeps_controller_score_snapshots_and_current_score" + }, + { + "id": "fixed-solo-first-completion", + "category": "adapter-behavior", + "summaryZh": "固定名次 Solo 适配器同帧仅结算第一个完成区间,匹配原生 Solo 顺序。", + "regression": "fixed_rank_solo_adapter_keeps_solo_first_completion_per_frame" + }, + { + "id": "conditional-pool-initial-timestamps", + "category": "lifecycle-state-initialization", + "summaryZh": "未执行条件效果池的 execute / finish 时间初始化为 -1;这是状态一致性修复,未据此声称分数漏洞。", + "regression": "unused_condition_pool_timestamps_remain_unset_until_execution" + }, + { + "id": "raw-just-mission-enabled", + "category": "judgement-window-integration", + "summaryZh": "Just 任务开始时在原始判定前启用 Just,结束时恢复 ForceEnableJust 设置,包含 Assist 窗口副本。", + "regressions": [ + "just_fever_enables_before_raw_notes_and_percent_miss_cap_then_disables_at_end", + "just_end_restores_force_setting_and_combo_ranges_do_not_change_it" + ], + "qualification": "The special early-side 2-to-3 ms mismatch was a consequence of the missing enabled-state wiring, not a separately established arithmetic bug." + } + ], + "featuresAndStatistics": [ + { + "id": "fixed-rank-solo-estimate", + "category": "model-adapter-feature", + "summaryZh": "固定名次 Solo 估计供 chart-stats 的基线、权重与校验统一使用;不冒充真实网络排名服务器。" + }, + { + "id": "shared-per-note-replay", + "category": "shared-model-api-feature", + "summaryZh": "逐音符给定判定与完整 RawResult 重放使用同一个 Rust LiveModel,明确 seed、帧序和名次。", + "integrationTests": 5 + }, + { + "id": "readonly-stage-getters", + "category": "observation-api-feature", + "summaryZh": "公开转换后判定、当前连击,以及排名前 frame_score 和排名后 score 两阶段的真实缓存值,不新增公式。" + }, + { + "id": "adaptive-stopping-both-score-endpoints", + "category": "statistics-correction", + "summaryZh": "适性采样同时检查最高判定端和 Perfect 端的标准误及各自基线,按同一 seed 前缀扩展批次;未放宽原 bound 或停止阈值。", + "qualification": "This statistical correction is separate from native runtime arithmetic repairs." + } + ], + "rawJustProductionReplay": { + "frames": 1600, + "timingTypeCount": 9, + "unitCount": 39, + "windowFieldComparisons": 388800, + "otherFieldComparisons": 62400, + "differenceCount": 0, + "driverSha256": "ce9631182a5690e8e0c9f7ac17a413350296d718d2a82e4f75a1656d67593664", + "nativeCaptureSha256": "f866b89277cc0d868e569c9c92bb017ff7fbd44422e0597ff0c4c078cf2c6cef", + "inputDataSha256": "ef87c0ba383ad3b72c2ddcf40455d7462c586fb1cd8d2abcc84ebdbbc0bda538", + "traceSha256": "2e5323c7a26f36b43a035eeb02b5d68de65a2fcb43483ff7c1f7e68de229b7ce", + "logSha256": "91146df9fb42919f015b0bec8b2dd0ce075495fa734c67133832de07dedca0ef", + "status": "equal_declared_fields_using_unchanged_production_library_public_apis", + "totalFieldComparisons": 451200, + "distinctNativeCapturesAdded": 0, + "productionSourceTreeSha256": "9d647e0527f1bbd2c0bf832f7536161ebd0399413ff208d800a8cd62cf1f5830", + "windowFields": [ + "judgement", + "enabled", + "baseBeforeMs", + "baseAfterMs", + "beforeMs", + "afterMs" + ], + "otherFieldGroups": [ + "frame-result score before ranking", + "calculator score after ranking", + "life", + "current combo", + "random draws", + "converted note-result order", + "six post-ranking float32 bit patterns", + "nine fields in each Gekisou range" + ], + "qualification": "Reuse the same 1600-frame native capture; replay native-origin NoteResult and originDiffMs through frame_raw_timed. No private observation hook or source patch is present in the production library. This does not claim independent physical-touch classification or repeat the existing full-pool experiment count." + }, + "existingProbability4011Capability": { + "status": "verified_real_card_skill_and_support_chain_in_two_cases", + "safeSummarySha256": "a941a55f35556baf84ace45167c7cbed0b071bb4607d62f56a9d04dfe8c1f3c0", + "frames": 15000, + "coreComparisons": 10680000, + "probabilityOperandResultComparisons": 72, + "coreDifferences": 0, + "probabilityOperandResultDifferences": 0, + "qualification": "4011 support already existed. Real GK20/support66 probability chains and two life branches were measured; a targeted 5% success seed is branch coverage, not a distribution estimate. No ordinary 4011 skill is invented." + }, + "twoLengthProbeResolution": { + "status": "resolvedProbeInputMismatch", + "safeSummarySha256": "cafc198f8f70d573208fe6cecff9569ff69db6f93307f36b222ec497bb116633", + "historicalDifferenceCount": 5861, + "frames": 8000, + "correctedFieldComparisons": 5656000, + "remainingDifferences": 0, + "qualification": "Same native capture and all declared fields retained. The old diagnostic incorrectly equated audio/skill length 91667 and score-table length; Native uses last note position time 117187 + 1000 = 118187 for its score table." + }, + "coverageQualification": "These passing checks and finite native contracts do not certify every game mode, input type, card interaction or online IFix/server configuration. Given-result replay is distinct from independent touch replay; full official network ranking is distinct from fixed-rank Solo projections. Final published NN data, WASM/browser integration and songs-page acceptance have their own source and data identities." +} diff --git a/docs/validation/replay-2026-09-30.json b/docs/validation/replay-2026-09-30.json new file mode 100644 index 0000000..b4b402d --- /dev/null +++ b/docs/validation/replay-2026-09-30.json @@ -0,0 +1,31 @@ +{ + "format": "ournotes.replay-validation/1", + "stage": "preview-source-verified-final-pin-pending", + "compiledSourceTreeSha256": "771475183524230d381a3eb7868a2fe3015fe2c239a432e5d220b15b57011993", + "compiledSourceFileCount": 59, + "rustc": "1.98.1", + "wasmBindgen": "0.2.127", + "target": "wasm32-unknown-unknown", + "jsSha256": "0a83b183882212804a72c4b4833bc40ce8354cb35cae3cf67b2c14e058b560dd", + "wasmSha256": "010227b995a4a482045b7312b666ee37bf7aec07a75d47f842d491dd84628a2a", + "runtimeDeckDataSha256": "fb42c86153fc89a31847a8e033a1df88d9f21a268715137e388890e47772fecc", + "integrationTests": {"passed": 5, "failed": 0}, + "wasmChecks": { + "chartCount": 340, + "modes": ["normal", "soloGekisou"], + "callsPerMode": 1360, + "processedFramesPerMode": 7211016, + "plans": ["Perfect plus chart-native unscored Pass", "scored-note cyclic Perfect/Great/Good/Bad/Miss/Just plus Pass"], + "deterministicRepeat": true, + "unknownNoteRejected": true, + "normalReportSha256": "3aca0e9804755141fe3788b11a12b88c44410337eda4d4b3a95a5355ae59882b", + "soloReportSha256": "2e07523df18c4b93bbfb39466c327c5249c82e7cf2ec219af48956da7adab318" + }, + "rustWasmComparison": { + "matchedRuns": 1360, + "differences": 0, + "fields": ["score", "frameScore", "life", "maxFrameCombo", "judgements", "Solo ranges: mission/state/combo/maxCombo/justCount/startScore/endScore/luckPoints/luckGauge/rushCombo/lotResults/rankBonus"] + }, + "scope": "The same shared production Rust API and actual WebAssembly agree for the declared result streams. This is a runtime/transport check, not additional ARM64 native execution or physical touch validation.", + "pending": ["Final deck commit must match the compiled source tree before final engine metadata and data packaging."] +} diff --git a/docs/validation/two-length-resolution-2026-09-30.json b/docs/validation/two-length-resolution-2026-09-30.json new file mode 100644 index 0000000..c5e244c --- /dev/null +++ b/docs/validation/two-length-resolution-2026-09-30.json @@ -0,0 +1,104 @@ +{ + "format": "ournotes.native-probe-resolution/1", + "date": "2026-09-30", + "status": "resolvedProbeInputMismatch", + "nativeBinarySha256": "5a79dd5d9c479ed5a636193394f040f21a1f7b070d0869095e1af63b3f84ef88", + "sameNativeCaptureSha256": "d479871f12209d6c12d47772e7b4bba6a65c2033e0f2b2c773aff1e7bdfc2251", + "historicalComparisonSha256": "984efea1e7e051aad24130e986f8f38585ad6739226a586de33f7de8eff566cf", + "historicalDifferenceCount": 5861, + "historicalFirstDifference": { + "frameIndex": 6046, + "field": "settledScore", + "native": 328726, + "rust": 329321 + }, + "historicalInputs": { + "skillAudioLengthMs": 91667, + "scoreTableLengthMs": 91667, + "scoreTableSource": "diagnostic driver used None, falling back to audio length" + }, + "matchedNativeInputs": { + "skillAudioLengthMs": 91667, + "scoreTableLengthMs": 118187, + "scoreTableSource": "LastTimingNotePosition.TimeMs 117187 + 1000" + }, + "resolution": "Correct only the diagnostic score_music_length_ms input; keep the same native capture, audio/skill clock, frames, fields and arithmetic.", + "sourceEvidence": [ + { + "file_va_hex": "0x55c808c", + "metadata_name": "App.LiveLogic.LiveExecutor$$Initialize", + "hash_verified": true, + "c_sha256": "6a49a432c82ff52048cb466e9d164f8b2a37e115a14e40088847e04c41121b69" + }, + { + "file_va_hex": "0x55e560c", + "metadata_name": "App.LiveLogic.Score.LiveScoreController$$.ctor", + "hash_verified": true, + "c_sha256": "5bda2f11ac597c37e7560bb4c4b1dbb2ddc92d6a4bddb9922ea7d329df19fa31" + }, + { + "file_va_hex": "0x6a3a230", + "metadata_name": "FTLiveSimulator.MusicScore$$SetNoteList", + "hash_verified": true, + "c_sha256": "849846635495e067e43ebeaa4ba143426c2ae2f27653c45a6e23b0a957c9233c" + } + ], + "lastTimingNoteSelection": "Native MusicScore.SetNoteList takes the maximum position key, then the first note position at that key; it does not filter out Pass or other unscored operations.", + "rustTraceSha256": "b66f206247aa4d5f2250ec53e0dd25ad0096139f773698abae85584ef0b3b1a5", + "correctedDriverSha256": "dabdc768e363724c12b5f76b478f300afa1e7af9a99c6666d67c5b1b9e3b25b1", + "comparisonSha256": "4ff7433a3144bfa918ad6dddf44160453a281b42e7942b0843a6b21737e7d3da", + "inputDataSha256": "ef87c0ba383ad3b72c2ddcf40455d7462c586fb1cd8d2abcc84ebdbbc0bda538", + "frames": 8000, + "skillPoolCount": 70, + "fieldComparisons": 5656000, + "remainingDifferences": 0, + "declaredFieldsCompared": [ + "frame", + "timeMs", + "score", + "life", + "combo", + "settledScore", + "randomDraws" + ], + "rangeFieldsCompared": [ + "state", + "combo", + "maxCombo", + "justCount", + "startScore", + "endScore", + "luckPoints", + "luckGauge", + "rushCombo", + "rawJustCount" + ], + "skillFieldsCompared": [ + "skillType", + "index", + "poolIndex", + "effectId", + "state", + "executeMs", + "finishMs", + "extendedBits", + "cumulativeCount" + ], + "sameFrameStageQualification": "native score after ordinary/card applier and conditional RecalculateScore before Gekisou ranking versus existing Rust calculator state at same stage; settledScore is after ranking; no phase shift/tolerance", + "fullRequestedFieldCoverage": false, + "unobservedFields": [ + "maxCombo", + "judgedCount", + "judgedCount", + "float32Bits.accuracyAverage", + "conditional-skill parent skillState", + "elapsedBits", + "countDecrease" + ], + "qualifications": [ + "The historical failure and capture remain retained; this resolution does not remove or rewrite them.", + "This is a deliberately truncated audio/skill-clock diagnostic, not a claim of a complete playable audio lifecycle.", + "The complete song case uses independently measured real BGM length 120557 ms and score-table length 118187 ms.", + "Zero differences applies only to this declared contract. There is no shifted frame, tolerance, omitted failing field or claim of all game configurations." + ] +} diff --git a/src/bin/ournotes-deck.rs b/src/bin/ournotes-deck.rs index ac80fe2..cb3aae1 100644 --- a/src/bin/ournotes-deck.rs +++ b/src/bin/ournotes-deck.rs @@ -35,7 +35,7 @@ chart-stats measures every chart on the whole-live simulation (ournotes-deck.cha the weight of every score-up kind at every position, with Gekisou on per seed (--seeds N for charts with a luck range, default 8; rank 1, range weights for the other ranks, the Perfect play's scores) and with Gekisou off (offSeeds); and the chart's aptitude for Gekisou skills: each skill shape of its missions measured alone, not a best formation. ---aptitude-max-seeds N caps adaptive sampling (default 1024); --aptitude-cross-seeds N caps ordinary-skill cross +--aptitude-max-seeds N caps adaptive sampling (default 65536); --aptitude-cross-seeds N caps ordinary-skill cross terms (default 64). --no-gekisou-aptitude skips aptitude, leaving both aptitude fields null; baseline stats remain. Increments are seed means with standard errors, not the game's expectation; multiple skill increments cannot be added. --charts keeps the listed score ids in file order; --jobs N measures N charts at once (default 1). diff --git a/src/chartstats.rs b/src/chartstats.rs index 1066bdb..b430092 100644 --- a/src/chartstats.rs +++ b/src/chartstats.rs @@ -1,6 +1,7 @@ //! Chart statistics: what a chart contributes to the live score whatever the deck, measured on the whole-live -//! simulation ([`crate::live::full`]) in two scenarios: with Gekisou on (`seeds`), as a Gekisou live (Battle Live) -//! plays, and with Gekisou off (`off_seeds`), as a solo live (Free Live, Challenge Live) plays. +//! simulation ([`crate::live::full`]) in two scenarios: with Gekisou on (`seeds`), using the solo Gekisou +//! updater's score-query schedule, and with Gekisou off (`off_seeds`). These are scoring models, not complete +//! Free Live, Challenge Live or network Battle Live lifecycle emulations. //! //! With Gekisou on, the play is the theoretical best play of a live with Gekisou //! ([`JudgementStream::theoretical_best_gekisou`]): every judged note at its exact time, Just inside the Just-count @@ -11,8 +12,11 @@ //! seed when no range is a luck range (the play then draws nothing), else the first [`published_seeds`]. The seed set //! is not the game's seed law (unknown), so a mean over it is not a native expectation. //! -//! Other ranks follow without playing again. A range's rank bonus is `trunc(rangeScore * percent / 100)`, added as a -//! fixed score in the frame of the range's end; it changes no factor and no note score, and a range's score (its end +//! Other ranks are counterfactual fixed-rank estimates on the same solo score-query path +//! ([`full::LiveModel::new_gekisou_ranked`]). Opponents are not simulated. Native network ranking instead reads +//! the controller's frame snapshots, so these estimates are not a native multiplayer frame replay. +//! A range's rank bonus is `trunc(rangeScore * percent / 100)`, queued at completion with the range's end +//! timestamp and consumed by a later score update; it changes no factor and no note score, and a range's score (its end //! score minus its start score) holds an earlier range's bonus at both ends, so it does not depend on the ranks. At //! ranks `r_i` the no-skill score is then exact ([`SeedStats::score_at_ranks`]) and every weight moves by the range //! weights ([`SeedStats::weights_at_ranks`]). The ranks reach the skills only through the confirmed-rank condition @@ -33,7 +37,7 @@ //! targets and conditions, value aside) and performance position `k`: `weights[kind][k]`, the exact score gained //! by a deck whose position-`k` member has one such effect at a factor of 1 (value 10000), divided by //! [`POWER`]. The skill runs through the simulation's own updaters, conditions, frames and appliers, so the -//! weight carries every rule of the game: its execute and finish frames, the 40 ms score frames, the combo and +//! weight carries the modeled rules: its execute and finish frames, the 40 ms score frames, the combo and //! Gekisou combo factors, Just scores, luck rushes and the rank bonuses of the ranges it overlaps; //! - with Gekisou on, per range `i` also `range_weights[kind][k][i]`: the range score that effect gains, divided by //! [`POWER`]. @@ -612,7 +616,8 @@ impl Live<'_> { Ok((score, ranges)) } - /// [`Live::run_deck`], also the number of judgements converted by skills. + /// [`Live::run_deck`], also the number of judgements converted by skills. Fixed-rank checks retain the same + /// solo timestamp-query definition as the baseline and weights; they are not network opponent simulations. #[allow(clippy::too_many_arguments)] fn run_counted( &self, @@ -645,7 +650,7 @@ impl Live<'_> { None => full::LiveModel::new_gekisou(master, &deck, self.notes, self.events, params, &g.setup)?, Some(ranks) => { let mut lm = - full::LiveModel::new_gekisou_external(master, &deck, self.notes, self.events, params, &g.setup)?; + full::LiveModel::new_gekisou_ranked(master, &deck, self.notes, self.events, params, &g.setup)?; for (i, &(rank, percent)) in ranks.iter().enumerate() { lm.queue_gekisou_rank_confirmation(i, rank, percent)?; } diff --git a/src/chartstats/aptitude.rs b/src/chartstats/aptitude.rs index e8e6190..d815b07 100644 --- a/src/chartstats/aptitude.rs +++ b/src/chartstats/aptitude.rs @@ -16,7 +16,7 @@ //! dependencies (luck ranges, luck effects 11000..=11005 and probability conditions 4011) and checking agreement on //! [`DETERMINISTIC_TEST`] seeds. It is then given on one seed; other shapes use seed batches //! ([`BATCHES`]) until the standard error of its score increment is at most the larger of [`RELATIVE`] of the -//! increment and [`BASELINE`] of the no-skill score. The cross term, the change of the plain kind's weights, uses +//! increment and [`BASELINE`] of the corresponding no-skill score for BOTH the best and Perfect plays. The cross term, the change of the plain kind's weights, uses //! the first `cross_seeds` of them. //! //! The increments of several shapes do not add up: the Gekisou combo factor saturates, the luck rush support skills @@ -37,13 +37,13 @@ use crate::master::{GekisouSkillEffectRow, Master, SkillRow}; /// Seeds a shape's increments must agree on to be deterministic (the first published seeds). pub const DETERMINISTIC_TEST: usize = 4; /// Seed batches of a shape that is not deterministic: the first seeds of the published set. -pub const BATCHES: [usize; 6] = [32, 64, 128, 256, 512, 1024]; +pub const BATCHES: [usize; 12] = [32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536]; /// The standard error target relative to the score increment. pub const RELATIVE: f64 = 0.01; /// The standard error target relative to the no-skill score. pub const BASELINE: f64 = 0.001; /// Default most seeds of a shape. -pub const MAX_SEEDS: usize = 1024; +pub const MAX_SEEDS: usize = 65536; /// Default most seeds of the cross term. pub const CROSS_SEEDS: usize = 64; /// Skill condition type of a member target: a skill's condition on its own member (the band condition). @@ -65,7 +65,7 @@ pub const MODEL: &str = "charts[].gekisouAptitude: every Gekisou skill shape of its Perfect play (scorePerfect); increments with minus without on the same seed as [mean, standard error]; a \ shape of another mission adds nothing (its skills trigger only while a range of their mission is concerned); \ deterministic shapes on one seed, the others on seed batches until the standard error is at most max(1% of the \ - increment, 0.1% of the no-skill score); tail = score - sum_j (rangeScore_j + rankBonus_j); at ranks r_j the \ + increment, 0.1% of the corresponding no-skill score) for both score and scorePerfect; tail = score - sum_j (rangeScore_j + rankBonus_j); at ranks r_j the \ increment is tail + sum_j rangeScore_j * (1 + p_j(r_j) / 100) up to one point per range; weights and \ rangeWeights: the change of the plain kind's weights (cross term) on the first crossSeeds seeds; one check per \ variant at random ranks and a random plain deck at checkPower, failing beyond the flooring bound; increments \ @@ -520,7 +520,7 @@ struct BaseRun { #[derive(Clone, Debug, PartialEq)] struct Sample { seed: i32, - base_score: f64, + base_scores: [f64; 2], values: Vec, /// `(weights per position, range weights per position and range)` changes. cross: Option<(Vec, Vec>)>, @@ -654,7 +654,7 @@ impl Measure<'_, '_> { } _ => None, }; - Ok(Sample { seed, base_score: b.just.score as f64, values, cross }) + Ok(Sample { seed, base_scores: [b.just.score as f64, b.perfect.score as f64], values, cross }) } } @@ -672,6 +672,16 @@ fn mean_se(x: impl Iterator + Clone) -> [f64; 2] { [mean, (var / n as f64).sqrt()] } +/// Both exported score plays must converge against their own paired no-skill baseline. +fn score_targets_met(samples: &[Sample]) -> bool { + samples.len() >= 2 + && (0..2).all(|i| { + let [mean, se] = mean_se(samples.iter().map(|s| s.values[i])); + let base = samples.iter().map(|s| s.base_scores[i]).sum::() / samples.len() as f64; + se <= (RELATIVE * mean.abs()).max(BASELINE * base) + }) +} + /// Rounded for the output: points to 1e-3. fn points(x: [f64; 2]) -> [f64; 2] { [(x[0] * 1000.0).round() / 1000.0, (x[1] * 1000.0).round() / 1000.0] @@ -784,9 +794,7 @@ pub(super) fn chart_aptitude( for (i, &s) in seeds.iter().enumerate().skip(samples.len()) { samples.push(m.sample(&p, s, i < inp.options.cross_seeds)?); } - let [mean, se] = mean_se(samples.iter().map(|s| s.values[0])); - let base = samples.iter().map(|s| s.base_score).sum::() / samples.len() as f64; - if se <= (RELATIVE * mean.abs()).max(BASELINE * base) { + if score_targets_met(&samples) { met = true; break; } @@ -901,3 +909,32 @@ fn check( .within(|| format!("Gekisou aptitude, seed {} at ranks {ranks:?}", s.seed))?; Ok(VariantCheck { seed: s.seed, ranks, deck, exact: c.exact, predicted: c.predicted, bound: c.bound }) } + +#[cfg(test)] +mod sampling_tests { + use super::*; + + #[test] + fn perfect_score_uses_its_own_baseline_and_must_also_converge() { + let mut samples: Vec<_> = (0..32) + .map(|i| Sample { + seed: i, + base_scores: [1_000_000.0, 1_000.0], + values: vec![100.0, if i % 2 == 0 { -200.0 } else { 200.0 }], + cross: None, + }) + .collect(); + assert_eq!(mean_se(samples.iter().map(|s| s.values[0])), [100.0, 0.0]); + // Checking only the first play, or using its much larger baseline for Perfect, would accept this. + assert!(!score_targets_met(&samples)); + for sample in &mut samples { + sample.values[1] = 50.0; + } + assert!(score_targets_met(&samples)); + } + + #[test] + fn random_sampling_never_calls_a_single_observation_converged() { + assert!(!score_targets_met(&[Sample { seed: 0, base_scores: [1000.0; 2], values: vec![0.0; 2], cross: None }])); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1baf557..1d75958 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ pub mod master; pub mod memory; pub mod num; pub mod power; +pub mod replay; pub mod scenario; pub mod search; diff --git a/src/live/full/engine.rs b/src/live/full/engine.rs index 3377296..f6f9d66 100644 --- a/src/live/full/engine.rs +++ b/src/live/full/engine.rs @@ -24,7 +24,7 @@ pub(crate) const SUSTAINED: i64 = 2; const POOL: usize = 5; /// One effect state. -#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct EffectState { /// 0 Stay, 2 ExecuteFrame, 3 Executing, 4 EndFrame. pub state: u8, @@ -39,6 +39,22 @@ pub(crate) struct EffectState { pub cumulative_max: i64, } +impl Default for EffectState { + fn default() -> Self { + // Native SkillEffectState..ctor (0x5618afc) stores -1 in both timestamp fields. + // An unused pool instance has no execution or finish time yet. + Self { + state: STAY, + execute_ms: -1, + finish_ms: -1, + extended_ms: 0.0, + cumulative_count: 0, + cumulative_unit: 0, + cumulative_max: 0, + } + } +} + /// The frame values the updaters read. #[derive(Clone, Copy, Debug)] pub(crate) struct FrameInput { @@ -593,6 +609,19 @@ mod lifecycle_tests { u.update(2, FrameInput { time_ms: time, music_length_ms: 10_000, is_live_finished: finished }, &mut ctx) } + #[test] + fn unused_condition_pool_timestamps_remain_unset_until_execution() { + let mut u = timed(Some(Checker::Fixed(false))); + for time in [0, 900] { + assert!(step(&mut u, time, false, &[]).unwrap().is_empty()); + assert!(u.updaters.iter().all(|p| (p.state.execute_ms, p.state.finish_ms) == (-1, -1))); + } + u.effects[0].condition = Some(Checker::Fixed(true)); + assert_eq!(step(&mut u, 1000, false, &[]).unwrap(), vec![0]); + assert_eq!((u.updaters[0].state.execute_ms, u.updaters[0].state.finish_ms), (1000, -1)); + assert!(u.updaters[1..].iter().all(|p| (p.state.execute_ms, p.state.finish_ms) == (-1, -1))); + } + #[test] fn timed_sustained_allocates_before_updating_older_instances() { let mut u = timed(Some(Checker::Fixed(true))); diff --git a/src/live/full/mod.rs b/src/live/full/mod.rs index 3d5b0f2..3419034 100644 --- a/src/live/full/mod.rs +++ b/src/live/full/mod.rs @@ -193,7 +193,8 @@ pub struct GekisouRange { pub combo: i32, pub max_combo: i32, pub just_count: i32, - /// The score when the range started and ended; recomputed at its start and end times when it completes. + /// Controller score snapshots at range start and end. Solo ranking replaces these with timestamp queries + /// for the completion it ranks; the native-network adapter retains the original frame snapshots. pub start_score: i32, pub end_score: i32, pub luck_points: i32, @@ -403,6 +404,7 @@ struct GekisouLive { fever: FeverUpdater, factors: [[i64; 5]; 3], external_ranking: bool, + solo_score_queries: bool, completed_scores: Vec, pending_ranks: Vec>, fever_updates: Vec<(usize, u8)>, @@ -538,6 +540,7 @@ pub struct LiveModel { judged: Vec<(i32, i32, i32)>, trace: Vec<(i32, i32)>, frame_time: i32, + frame_score: i32, prev_confirmed_rank: Option, /// Simulator timing-combo snapshot and the separately reset live combo controller. simulator_previous_combo: i32, @@ -681,7 +684,28 @@ impl LiveModel { LiveModel::build(master, deck, notes, skill_events, params, Some(setup), true) } - /// Queue a confirmed rank and its explicit bonus percentage. Applied after the range has completed. + /// Counterfactual solo scoring with explicitly queued ranks and percentages. Range scores use + /// the solo updater's exact start/end timestamp queries. This is an adapter for chart statistics + /// and rank sensitivity; like solo, only the first completion in a frame is ranked. Native solo + /// always confirms rank 1, and this does not emulate network play. + pub fn new_gekisou_ranked( + master: &Master, + deck: &[Performer], + notes: &[LiveNote], + skill_events: &[(i32, i32)], + params: LiveParams, + setup: &GekisouSetup, + ) -> Result { + let mut model = LiveModel::build(master, deck, notes, skill_events, params, Some(setup), false)?; + let gk = model.gk.as_mut().expect("Gekisou setup supplied"); + gk.external_ranking = true; + gk.solo_score_queries = true; + Ok(model) + } + + /// Queue a confirmed rank and its explicit bonus percentage for an externally ranked adapter. + /// `new_gekisou_external` uses native network frame snapshots; `new_gekisou_ranked` uses solo timestamp + /// queries for counterfactual fixed ranks. Applied after the range has completed. /// Identical retries are idempotent; conflicting confirmations are errors. These are adapter policies. /// Multiple confirmations applied together publish the last range's rank through the native scalar event. pub fn queue_gekisou_rank_confirmation(&mut self, range: usize, rank: i32, percent: i64) -> Result<(), Error> { @@ -933,6 +957,7 @@ impl LiveModel { judged: Vec::new(), trace: Vec::new(), frame_time: 0, + frame_score: 0, prev_confirmed_rank: None, simulator_previous_combo: 0, current_combo: 0, @@ -987,6 +1012,7 @@ impl LiveModel { fever: FeverUpdater::new(&setup.fevers), factors, external_ranking, + solo_score_queries: false, completed_scores: vec![false; n], pending_ranks: vec![None; n], fever_updates: Vec::new(), @@ -1068,6 +1094,22 @@ impl LiveModel { self.life.current_life } + /// Live combo controller's current value, after this frame's converted results. + pub fn current_combo(&self) -> i32 { + self.current_combo + } + + /// This frame's converted `(note id, judgement, chart time_ms)` results, including unscored Pass events. + pub fn frame_judgements(&self) -> &[(i32, i32, i32)] { + &self.judged + } + + /// Native frame-result score, captured after skill updates and before Gekisou ranking's timestamp queries. + /// [`LiveModel::score`] remains the calculator's settled value after ranking. + pub fn frame_score(&self) -> i32 { + self.frame_score + } + /// Number of judgements converted by skills so far. pub fn converted_judgements(&self) -> u64 { self.conversion.converted @@ -1225,6 +1267,20 @@ impl LiveModel { let mut h = Handle { sc: &mut self.scorectl, score: &mut self.score }; let mut env = Env { random: &mut self.random, handle: &mut h }; gk.ctrl.before_update(dt, t, &gk.fever_updates, current, &mut env)?; + // PlayingState.OnBeforeUpdateGekisou dispatches each changed range before FT updates notes. + // Just missions enable grade 6 at Start, then restore the client's force setting at End. + if let Some(runtime) = self.raw_runtime.as_mut() { + for &idx in &gk.ctrl.state_updates { + if gk.ctrl.ranges[idx].mission == 3 { + let enabled = match gk.ctrl.states[idx].state { + gekisou::S_START => true, + gekisou::S_END => runtime.force_enable_just_judgement, + _ => continue, + }; + runtime.set_just_judgement_enabled(enabled); + } + } + } } self.frame_events.clear(); for (i, &(index, ev_t)) in self.events.iter().enumerate() { @@ -1260,8 +1316,12 @@ impl LiveModel { for (result_index, &(n, conv)) in results.iter().enumerate() { self.life.add_note_damage(n.time_ms, conv)?; let life = self.life.get_life_at_ms(n.time_ms)?; - let score_type = convert_score_type(conv as i64)?; - self.score.add_note(NoteCommand::new(n.time_ms, life, n.note_id, n.note_operate_type, score_type)); + // LiveScoreController.AddNoteScore checks the operation table before converting the score type. + // Unscored simulator results (including type 122 / Pass) still reach life, combo and callbacks. + if self.score.calc.note_factor_percent.contains_key(&n.note_operate_type) { + let score_type = convert_score_type(conv as i64)?; + self.score.add_note(NoteCommand::new(n.time_ms, life, n.note_id, n.note_operate_type, score_type)); + } // Window limit callbacks per consumed result (UpdateCurrentFrameParameters). if let (Some(note), Some(runtime)) = (raw.get(result_index), self.raw_runtime.as_mut()) { runtime.on_executor_judgement(*note)?; @@ -1363,6 +1423,7 @@ impl LiveModel { self.life.sync_current_life(t)?; let info = self.gk.as_ref().map(|g| &g.ctrl as &dyn GekisouComboInfo); self.score.calculate(t, &self.combo, info)?; + self.frame_score = self.score.score; if self.gk.is_some() { self.gekisou_after(t, &results)?; } @@ -1386,18 +1447,28 @@ impl LiveModel { gk.prev_lots.extend_from_slice(&gk.ctrl.lot_results); gk.prev_lot_ms = if gk.prev_lots.is_empty() { 0 } else { t }; if gk.external_ranking { - // Freeze every completed range's base score before adding any of this frame's ranking bonuses. + // Network ranking reads the range's score snapshots taken by the controller. Unlike solo + // ranking, it does not rewind the score calculator to the range's start and end timestamps. for idx in gk.ctrl.state_updates.iter().copied() { if gk.ctrl.states[idx].state != S_COMPLETE || gk.completed_scores[idx] { continue; } - let r = &gk.ctrl.ranges[idx]; - let info = Some(&gk.ctrl as &dyn GekisouComboInfo); - let s0 = self.score.calculate(r.start_ms, &self.combo, info)?; - let s1 = self.score.calculate(r.end_ms, &self.combo, info)?; - gk.ctrl.states[idx].start_score = s0; - gk.ctrl.states[idx].end_score = s1; + // The fixed-rank solo adapter retains solo's timestamp queries explicitly; network + // ranking keeps the controller snapshots and never enters this branch. + if gk.solo_score_queries { + let r = &gk.ctrl.ranges[idx]; + let info = Some(&gk.ctrl as &dyn GekisouComboInfo); + let s0 = self.score.calculate(r.start_ms, &self.combo, info)?; + let s1 = self.score.calculate(r.end_ms, &self.combo, info)?; + gk.ctrl.states[idx].start_score = s0; + gk.ctrl.states[idx].end_score = s1; + } gk.completed_scores[idx] = true; + // SoloGekisouRankingUpdater returns after the first completed range, including when + // overlapping ranges complete together. Keep that schedule in the solo adapter. + if gk.solo_score_queries { + break; + } } for idx in 0..gk.pending_ranks.len() { if !gk.completed_scores[idx] { diff --git a/src/live/full/raw_runtime.rs b/src/live/full/raw_runtime.rs index 5a99302..d6d618e 100644 --- a/src/live/full/raw_runtime.rs +++ b/src/live/full/raw_runtime.rs @@ -20,6 +20,8 @@ pub struct RawJudgedNote { #[derive(Clone, Debug)] pub struct RawJudgementRuntime { pub windows: WindowController, + /// Client ForceEnableJustJudgement setting; Just missions restore this at their end. + pub force_enable_just_judgement: bool, pub just: JustWindowApplier, pub assist: Option, pub diff_converter: fn(i32, i32) -> i32, @@ -35,6 +37,7 @@ impl RawJudgementRuntime { pub fn new(timings: Vec, just_base_expansion_ms: i32, original_just_before_ms: i32) -> Self { Self { windows: WindowController::new(timings), + force_enable_just_judgement: false, just: JustWindowApplier::new(just_base_expansion_ms, original_just_before_ms), diff_converter: |_, d| d, assist: None, @@ -63,6 +66,15 @@ impl RawJudgementRuntime { None => &self.windows.timings, } } + pub(super) fn set_just_judgement_enabled(&mut self, enabled: bool) { + for set in self.windows.timings.iter_mut().chain(self.windows.replicas.iter_mut().flatten()) { + for unit in &mut set.units { + if unit.judgement == 6 { + unit.enabled = enabled; + } + } + } + } pub(super) fn convert_assist(&mut self, note: &mut RawJudgedNote, unchanged_by_skill: bool) -> Result<(), Error> { if unchanged_by_skill && let Some(a) = self.assist.as_mut() { note.result.judgement = diff --git a/src/replay.rs b/src/replay.rs new file mode 100644 index 0000000..02a2e1e --- /dev/null +++ b/src/replay.rs @@ -0,0 +1,735 @@ +//! One declared play, using the same live model as the native validation and chart tools. +//! +//! A supplied judgement is a completed simulator result before skill conversion. This API does not infer +//! physical touch eligibility. Its clock, result order, skill order, seed and ranking policy are explicit. +use std::collections::{BTreeMap, HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +use crate::Error; +use crate::data::DeckData; +use crate::live::full::{JudgedNote, LiveModel, LiveNote, LiveParams, Performer, gekisou_rank_factors}; +use crate::live::full::{RawJudgedNote, RawJudgementRuntime}; +use crate::live::raw::{NoteResult, TimingUnit}; +use crate::live::raw_windows::TimingSet; +use crate::live::score::LiveScoreSettings; +use crate::live::skip::is_judgement_note; +use crate::scenario::Scenario; + +pub const REQUEST_FORMAT: &str = "ournotes.replay/1"; +pub const RESULT_FORMAT: &str = "ournotes.replay-result/1"; + +fn one() -> f32 { + 1.0 +} +fn yes() -> bool { + true +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReplayPerformer { + pub live_skill: Option<(i64, i64)>, + #[serde(default)] + pub support_skills: Vec<(i64, i64)>, + #[serde(default)] + pub band_id: i64, + #[serde(default)] + pub character_id: i64, + #[serde(default)] + pub card_type: i64, + #[serde(default)] + pub tag_ids: Vec, + #[serde(default)] + pub live_skill_categories: Vec, + #[serde(default)] + pub gekisou_skill_categories: Vec, + #[serde(default)] + pub gekisou_mission_type: i64, + pub gekisou_skill: Option<(i64, i64)>, + #[serde(default)] + pub gekisou_support_skills: Vec<(i64, i64)>, +} + +impl From<&ReplayPerformer> for Performer { + fn from(p: &ReplayPerformer) -> Self { + Self { + live_skill: p.live_skill, + support_skills: p.support_skills.clone(), + band_id: p.band_id, + character_id: p.character_id, + card_type: p.card_type, + tag_ids: p.tag_ids.clone(), + live_skill_categories: p.live_skill_categories.clone(), + gekisou_skill_categories: p.gekisou_skill_categories.clone(), + gekisou_mission_type: p.gekisou_mission_type, + gekisou_skill: p.gekisou_skill, + gekisou_support_skills: p.gekisou_support_skills.clone(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RankConfirmation { + pub frame: usize, + pub range: usize, + /// Native group ordinal, not competition/tie rank. + pub rank: i32, + pub percent: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +pub enum ReplayMode { + Normal, + SoloGekisou, + /// Counterfactual fixed placement with native Solo timestamp recount; not network ranking. + FixedSoloGekisou { + ranks: [i32; 3], + }, + /// Explicit external confirmation inputs with native network snapshots; no simulated opponents. + ExternalGekisou { + confirmations: Vec, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReplayJudgement { + pub note_id: i32, + /// 1 Miss, 2 Bad, 3 Good, 4 Great, 5 Perfect, 6 Just, 7 unscored Pass. + pub judgement: i32, + pub judgement_time_ms: i32, + /// Required only when rawRuntime is supplied. FT result metadata is caller input, not inferred touch data. + #[serde(default)] + pub raw_result: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReplayRawResult { + pub origin: i32, + pub timing: i32, + pub origin_diff_ms: i32, + pub diff_ms: i32, + #[serde(default)] + pub direction_mismatch: bool, + #[serde(default)] + pub is_easy_flick: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReplayRawRuntime { + /// Level-zero ordered timing units come from the loaded master, with no Assist tail hook. + pub just_base_expansion_ms: i32, + pub original_just_before_ms: i32, + #[serde(default)] + pub force_enable_just_judgement: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReplayFrame { + pub time_ms: i32, + pub delta_seconds: f32, + #[serde(default)] + pub judgements: Vec, + /// Explicit lifecycle input to conditional skills; the template derives it from completed input results. + #[serde(default)] + pub live_finished: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReplayRequest { + pub format: String, + pub score_id: i64, + pub power: i32, + /// Actual audio length, not an inferred last-note duration. + pub music_length_ms: i32, + pub score_music_length_ms: Option, + #[serde(default = "one")] + pub assist_factor: f32, + pub seed: i32, + pub performers: Vec, + /// A permutation of performer indexes. Does not shuffle member target identities. + pub skill_order: Vec, + pub mode: ReplayMode, + #[serde(default)] + pub raw_runtime: Option, + pub frames: Vec, + #[serde(default = "yes")] + pub complete: bool, + #[serde(default)] + pub trace: bool, +} + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JudgementCounts { + pub just: u32, + pub perfect: u32, + pub great: u32, + pub good: u32, + pub bad: u32, + pub miss: u32, + pub pass: u32, +} +impl JudgementCounts { + fn add(&mut self, grade: i32) { + match grade { + 1 => self.miss += 1, + 2 => self.bad += 1, + 3 => self.good += 1, + 4 => self.great += 1, + 5 => self.perfect += 1, + 6 => self.just += 1, + 7 => self.pass += 1, + _ => {} + } + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReplayRange { + pub mission: i64, + pub state: u8, + pub combo: i32, + pub max_combo: i32, + pub just_count: i32, + pub start_score: i32, + pub end_score: i32, + pub luck_points: i32, + pub luck_gauge: i32, + pub rush_combo: i32, + pub lot_results: [i32; 4], + pub rank_bonus: Option, +} +fn ranges(model: &LiveModel) -> Vec { + model + .gekisou_ranges() + .iter() + .map(|r| ReplayRange { + mission: r.mission, + state: r.state, + combo: r.combo, + max_combo: r.max_combo, + just_count: r.just_count, + start_score: r.start_score, + end_score: r.end_score, + luck_points: r.luck_points, + luck_gauge: r.luck_gauge, + rush_combo: r.rush_combo, + lot_results: r.lot_results, + rank_bonus: r.rank_bonus, + }) + .collect() +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BonusEvent { + pub frame: usize, + pub range: usize, + pub rank: i32, + pub points: i32, + pub percent: i64, +} +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FrameResult { + pub frame: usize, + pub time_ms: i32, + pub frame_score: i32, + pub score: i32, + pub life: i32, + pub combo: i32, + pub converted_judgements: Vec<(i32, i32, i32)>, + pub ranges: Vec, +} +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReplayResult { + pub format: &'static str, + pub input_kind: &'static str, + pub score_id: i64, + pub complete: bool, + pub mode: ReplayMode, + pub frame_count: usize, + pub end_time_ms: i32, + /// Same-frame native renderer cache, before a possible Solo ranking rewind. + pub frame_score: i32, + /// Settled calculator channel, matching production LiveModel::score(). + pub score: i32, + pub life: i32, + pub combo: i32, + /// Maximum sampled live-frame combo. Not the unvalidated FT internal per-note maximum. + pub max_frame_combo: i32, + pub judgements: JudgementCounts, + pub converted_judgements: u64, + pub random_draws: u64, + pub ranges: Vec, + pub bonus_events: Vec, + pub frames: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NoteDescription { + pub note_id: i32, + pub note_ms: i32, + pub op_type: i32, + pub judgement_type: i32, + pub default_judgement: i32, +} +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChartDescription { + pub format: &'static str, + pub score_id: i64, + pub music_id: i64, + pub music_length_ms: Option, + /// Native LiveScoreController: chart.LastTimingNotePosition.TimeMs + 1000, independently of audio. + pub score_music_length_ms: i32, + pub asset_sha256: String, + pub notes: Vec, + pub skill_events: Vec<(i32, i32)>, + pub fevers: Vec<(i32, i32)>, + pub missions: [i64; 3], + pub default_performers: Vec, + pub default_skill_order: Vec, + pub default_seed: i32, + pub default_fps: u32, +} + +/// A parsed, reusable data session. The WASM bridge owns one of these per loaded data version. +pub struct ReplaySession { + data: DeckData, +} + +impl ReplaySession { + pub fn new(data: DeckData) -> Self { + Self { data } + } + pub fn from_json(data_json: &str) -> Result { + Ok(Self::new(DeckData::from_json(data_json)?)) + } + + fn music_id(&self, score_id: i64) -> Result { + let mut ids = self + .data + .master + .live_musics + .iter() + .filter(|m| [m.easy_id, m.normal_id, m.hard_id, m.expert_id].contains(&score_id)); + let id = ids.next().ok_or_else(|| Error::Input(format!("chart {score_id} has no base music row")))?.id; + if ids.next().is_some() { + return Err(Error::Input(format!("chart {score_id} has ambiguous base music rows"))); + } + Ok(id) + } + + pub fn describe_chart(&self, score_id: i64) -> Result { + let chart = self.data.data_chart(score_id).ok_or_else(|| Error::Input(format!("unknown chart {score_id}")))?; + let music_id = self.music_id(score_id)?; + let scene = Scenario::Free(music_id).resolve(&self.data.master)?; + let score_chart = chart.chart(&LiveScoreSettings::from_master(&self.data.master)?)?; + let length = self + .data + .provenance + .get("replay") + .and_then(|v| v.get("musicLengthsMs")) + .and_then(|v| v.get(score_id.to_string())) + .and_then(|v| v.as_i64()) + .and_then(|v| i32::try_from(v).ok()) + .filter(|&v| v > 0); + Ok(ChartDescription { + format: "ournotes.replay-chart/1", + score_id, + music_id, + music_length_ms: length, + score_music_length_ms: score_chart.last_timing_note_ms.wrapping_add(1000), + asset_sha256: chart.asset_sha256.clone(), + notes: chart + .notes + .iter() + .zip(&chart.judgement_types) + .map(|(n, &jt)| NoteDescription { + note_id: n.id, + note_ms: n.time_ms, + op_type: n.note_type, + judgement_type: jt, + default_judgement: if is_judgement_note(n.note_type) { 5 } else { 7 }, + }) + .collect(), + skill_events: chart.skill_events().iter().map(|e| (e.index, e.time_ms)).collect(), + fevers: chart.fevers.clone(), + missions: scene.gekisou_missions, + default_performers: vec![ReplayPerformer::default(); 5], + default_skill_order: (0..5).collect(), + default_seed: 0, + default_fps: 60, + }) + } + + /// Synthetic explicit Perfect/Pass result plan; never guesses Just or physical touch eligibility. + /// Notes retain chart enumeration order within a frame. The requested clock remains visible in the request. + pub fn template(&self, score_id: i64, power: i32, fps: u32) -> Result { + if !matches!(fps, 30 | 60 | 120) { + return Err(Error::Input("template fps must be 30, 60 or 120".into())); + } + if power < 0 { + return Err(Error::Input("power must be non-negative".into())); + } + let d = self.describe_chart(score_id)?; + let length = d.music_length_ms.ok_or_else(|| { + Error::Input("actual musicLengthMs is absent from data provenance.replay.musicLengthsMs".into()) + })?; + let max_note = d.notes.iter().map(|n| n.note_ms).max().unwrap_or(0); + let max_event = d.skill_events.iter().map(|e| e.1).max().unwrap_or(0); + let max_fever = d.fevers.iter().map(|e| e.1).max().unwrap_or(0); + let tail = length + .max(max_note) + .max(max_event) + .max(max_fever) + .checked_add(2000) + .ok_or_else(|| Error::Input("template tail clock overflow".into()))?; + let end_frame = (i64::from(tail) * i64::from(fps) + 999) / 1000; + let final_note_frame = (i64::from(max_note.max(0)) * i64::from(fps) + 999) / 1000; + let mut frames: Vec<_> = (0..=end_frame) + .map(|i| ReplayFrame { + time_ms: (i * 1000 / i64::from(fps)) as i32, + delta_seconds: 1.0 / fps as f32, + judgements: Vec::new(), + live_finished: i >= final_note_frame, + }) + .collect(); + for n in d.notes { + let frame = ((i64::from(n.note_ms.max(0)) * i64::from(fps) + 999) / 1000) as usize; + frames[frame].judgements.push(ReplayJudgement { + note_id: n.note_id, + judgement: n.default_judgement, + judgement_time_ms: n.note_ms, + raw_result: None, + }); + } + Ok(ReplayRequest { + format: REQUEST_FORMAT.into(), + score_id, + power, + music_length_ms: length, + score_music_length_ms: Some(d.score_music_length_ms), + assist_factor: 1.0, + seed: 0, + performers: d.default_performers, + skill_order: d.default_skill_order, + mode: ReplayMode::Normal, + raw_runtime: None, + frames, + complete: true, + trace: false, + }) + } + + pub fn describe_chart_json(&self, score_id: i64) -> Result { + json(&self.describe_chart(score_id)?) + } + pub fn template_json(&self, score_id: i64, power: i32, fps: u32) -> Result { + json(&self.template(score_id, power, fps)?) + } + pub fn run_json(&self, request_json: &str) -> Result { + let request: ReplayRequest = + serde_json::from_str(request_json).map_err(|e| Error::Input(format!("replay JSON: {e}")))?; + json(&self.run(&request)?) + } + + pub fn run(&self, r: &ReplayRequest) -> Result { + if r.format != REQUEST_FORMAT { + return Err(Error::Input(format!("unsupported replay format {}", r.format))); + } + if r.power < 0 || r.music_length_ms <= 0 || !r.assist_factor.is_finite() || r.assist_factor < 0.0 { + return Err(Error::Input("power, actual music length or assist factor is invalid".into())); + } + if !r.score_music_length_ms.is_some_and(|v| v > 0) { + return Err(Error::Input( + "explicit positive scoreMusicLengthMs is required; audio length is independent".into(), + )); + } + if r.performers.is_empty() + || r.performers.len() > 5 + || r.skill_order.len() != r.performers.len() + || r.skill_order.iter().copied().collect::>().len() != r.performers.len() + || r.skill_order.iter().any(|&i| i >= r.performers.len()) + { + return Err(Error::Input("skillOrder must be a permutation of one to five performers".into())); + } + if r.frames.is_empty() + || r.frames.windows(2).any(|w| w[1].time_ms <= w[0].time_ms) + || r.frames.iter().any(|f| !f.delta_seconds.is_finite() || f.delta_seconds < 0.0) + { + return Err(Error::Input( + "frames need a strictly increasing clock and finite non-negative binary32 deltaSeconds".into(), + )); + } + let chart = + self.data.data_chart(r.score_id).ok_or_else(|| Error::Input(format!("unknown chart {}", r.score_id)))?; + for (member, p) in r.performers.iter().enumerate() { + let m = &self.data.master; + if p.live_skill.is_some_and(|(id, level)| { + !m.live_skill_effects.iter().any(|e| e.live_skill_id == id && e.level == level) + }) || p.support_skills.iter().any(|&(id, level)| { + !m.support_skill_effects.iter().any(|e| e.support_skill_id == id && e.level == level) + }) || p.gekisou_skill.is_some_and(|(id, level)| { + !m.gekisou_skill_effects.iter().any(|e| e.skill_id == id && e.level == level) + }) || p.gekisou_support_skills.iter().any(|&(id, level)| { + !m.gekisou_support_skill_effects.iter().any(|e| e.skill_id == id && e.level == level) + }) { + return Err(Error::Input(format!("performer {member}: unknown skill id/level"))); + } + } + let settings = LiveScoreSettings::from_master(&self.data.master)?; + let c = chart.chart(&settings)?; + let notes: Vec<_> = chart + .notes + .iter() + .zip(&chart.judgement_types) + .map(|(n, &jt)| LiveNote { + note_id: n.id, + time_ms: n.time_ms, + note_operate_type: n.note_type, + judgement_type: jt, + }) + .collect(); + let by_id: HashMap<_, _> = notes.iter().map(|n| (n.note_id, n)).collect(); + if by_id.len() != notes.len() { + return Err(Error::Input("chart contains duplicate note ids".into())); + } + let mut seen = HashSet::new(); + for (i, f) in r.frames.iter().enumerate() { + for j in &f.judgements { + let note = by_id + .get(&j.note_id) + .ok_or_else(|| Error::Input(format!("frame {i}: unknown note {}", j.note_id)))?; + if !seen.insert(j.note_id) { + return Err(Error::Input(format!("note {} has more than one supplied final result", j.note_id))); + } + if !(1..=7).contains(&j.judgement) + || (!is_judgement_note(note.note_operate_type) && j.judgement != 7) + || (is_judgement_note(note.note_operate_type) && j.judgement == 7) + || j.judgement_time_ms > f.time_ms + { + return Err(Error::Input(format!("frame {i}: invalid result grade/time for note {}", j.note_id))); + } + if r.raw_runtime.is_some() != j.raw_result.is_some() + || j.raw_result + .as_ref() + .is_some_and(|raw| !(1..=7).contains(&raw.origin) || !(0..=3).contains(&raw.timing)) + { + return Err(Error::Input(format!( + "frame {i}: rawRuntime requires explicit valid rawResult on every judgement" + ))); + } + } + } + if r.complete && (seen.len() != notes.len() || r.frames.last().unwrap().time_ms < r.music_length_ms) { + return Err(Error::Input("complete replay requires every chart result and a clock through actual musicLengthMs; missing results are not filled".into())); + } + let music_id = self.music_id(r.score_id)?; + let scene = Scenario::Free(music_id).resolve(&self.data.master)?; + let setup = scene.gekisou_setup(&chart.fevers); + let row = + self.data.master.live_music_score(r.score_id).ok_or_else(|| Error::Input("missing score row".into()))?; + let params = LiveParams { + skill_target_music_type: scene.skill_target_music_type, + total_power: r.power, + music_level: i32::try_from(row.music_score_level) + .map_err(|_| Error::Master("music level exceeds i32".into()))?, + converted_note_count: c.converted_note_count, + music_length_ms: r.music_length_ms, + score_music_length_ms: r.score_music_length_ms, + assist_factor: r.assist_factor, + }; + let deck: Vec<_> = r.performers.iter().map(Performer::from).collect(); + let mut events = Vec::new(); + for e in chart.skill_events() { + let position = + usize::try_from(e.index).map_err(|_| Error::Input("negative chart skill event index".into()))?; + let index = *r + .skill_order + .get(position) + .ok_or_else(|| Error::Input("chart skill event exceeds skillOrder; no implicit cycling".into()))?; + events.push((index as i32, e.time_ms)); + } + let mut model = match &r.mode { + ReplayMode::Normal => LiveModel::new(&self.data.master, &deck, ¬es, &events, params)?, + ReplayMode::SoloGekisou => { + LiveModel::new_gekisou(&self.data.master, &deck, ¬es, &events, params, &setup)? + } + ReplayMode::FixedSoloGekisou { ranks } => { + if ranks.iter().any(|rank| !(1..=5).contains(rank)) { + return Err(Error::Input("fixed Solo ranks must be 1..=5".into())); + } + let mut m = LiveModel::new_gekisou_ranked(&self.data.master, &deck, ¬es, &events, params, &setup)?; + let factors = gekisou_rank_factors(&self.data.master, &scene.gekisou_missions)?; + for (range, &rank) in ranks.iter().enumerate().take(setup.fevers.len()) { + m.queue_gekisou_rank_confirmation(range, rank, factors[range][rank as usize - 1])?; + } + m + } + ReplayMode::ExternalGekisou { .. } => { + LiveModel::new_gekisou_external(&self.data.master, &deck, ¬es, &events, params, &setup)? + } + }; + if let Some(raw) = &r.raw_runtime { + if raw.just_base_expansion_ms < 0 || raw.original_just_before_ms < 0 { + return Err(Error::Input("raw window bases must be non-negative".into())); + } + let mut timing_rows: Vec<_> = + self.data.master.live_judgement_timings.iter().filter(|row| row.assist_level == 0).collect(); + timing_rows.sort_by_key(|row| (row.note_judgement_type, row.judgement_priority)); + let mut sets: BTreeMap> = BTreeMap::new(); + for row in timing_rows { + let integer = |v| i32::try_from(v).map_err(|_| Error::Master("raw timing exceeds i32".into())); + let grade = integer(row.note_simulate_judgement)?; + let mut unit = TimingUnit::new(grade, integer(row.before_ms)?, integer(row.after_ms)?); + if grade == 6 { + unit.enabled = raw.force_enable_just_judgement; + } + sets.entry(integer(row.note_judgement_type)?).or_default().push(unit); + } + if notes.iter().any(|n| !sets.contains_key(&n.judgement_type)) { + return Err(Error::Master("raw timing units absent for a chart judgement type".into())); + } + let mut runtime = RawJudgementRuntime::new( + sets.into_iter().map(|(judgement_type, units)| TimingSet { judgement_type, units }).collect(), + raw.just_base_expansion_ms, + raw.original_just_before_ms, + ); + runtime.force_enable_just_judgement = raw.force_enable_just_judgement; + model.enable_raw_runtime(runtime)?; + } + let mut confirmations: BTreeMap> = BTreeMap::new(); + if let ReplayMode::ExternalGekisou { confirmations: inputs } = &r.mode { + let factors = gekisou_rank_factors(&self.data.master, &scene.gekisou_missions)?; + let mut ranges = HashSet::new(); + for c in inputs { + if c.frame >= r.frames.len() + || c.range >= setup.fevers.len().min(3) + || !(1..=5).contains(&c.rank) + || !ranges.insert(c.range) + || c.percent != factors[c.range][c.rank as usize - 1] + { + return Err(Error::Input("external confirmation needs one known range, in-frame group rank and matching master percentage".into())); + } + confirmations.entry(c.frame).or_default().push(c); + } + } + model.set_seed(r.seed); + let mut counts = JudgementCounts::default(); + let mut trace = Vec::new(); + let mut bonuses = Vec::new(); + let mut bonus_count = 0; + let mut max_frame_combo = 0; + for (i, f) in r.frames.iter().enumerate() { + if let Some(cs) = confirmations.get(&i) { + for c in cs { + model.queue_gekisou_rank_confirmation(c.range, c.rank, c.percent)?; + } + } + model.set_live_finished(f.live_finished); + if r.raw_runtime.is_some() { + let judged: Vec<_> = f + .judgements + .iter() + .map(|j| { + let raw = j.raw_result.as_ref().expect("validated raw input"); + RawJudgedNote { + note_id: j.note_id, + result: NoteResult { + origin: raw.origin, + judgement: j.judgement, + judgement_type: by_id[&j.note_id].judgement_type, + timing: raw.timing, + time_ms: j.judgement_time_ms, + origin_diff_ms: raw.origin_diff_ms, + diff_ms: raw.diff_ms, + }, + direction_mismatch: raw.direction_mismatch, + is_easy_flick: raw.is_easy_flick, + } + }) + .collect(); + model.frame_raw_timed(f.time_ms, &judged, f.delta_seconds)?; + } else { + let judged: Vec<_> = f + .judgements + .iter() + .map(|j| JudgedNote { + note_id: j.note_id, + judgement: j.judgement, + judgement_time_ms: j.judgement_time_ms, + }) + .collect(); + model.frame_timed(f.time_ms, &judged, f.delta_seconds)?; + } + for &(_, grade, _) in model.frame_judgements() { + counts.add(grade); + } + max_frame_combo = max_frame_combo.max(model.current_combo()); + for &(range, rank, points, percent) in &model.gekisou_rank_bonuses()[bonus_count..] { + bonuses.push(BonusEvent { frame: i, range, rank, points, percent }); + } + bonus_count = model.gekisou_rank_bonuses().len(); + if r.trace { + trace.push(FrameResult { + frame: i, + time_ms: f.time_ms, + frame_score: model.frame_score(), + score: model.score(), + life: model.current_life(), + combo: model.current_combo(), + converted_judgements: model.frame_judgements().to_vec(), + ranges: ranges(&model), + }); + } + } + let final_ranges = ranges(&model); + if r.complete + && !matches!(r.mode, ReplayMode::Normal) + && final_ranges.iter().any(|s| s.state != 8 || s.rank_bonus.is_none()) + { + return Err(Error::Input("complete Gekisou replay ends before every range and rank confirmation settled; extend the explicit clock/input".into())); + } + Ok(ReplayResult { + format: RESULT_FORMAT, + input_kind: if r.raw_runtime.is_some() { + "givenRawResultsBeforeSkillConversion" + } else { + "givenJudgementsBeforeSkillConversion" + }, + score_id: r.score_id, + complete: r.complete, + mode: r.mode.clone(), + frame_count: r.frames.len(), + end_time_ms: r.frames.last().unwrap().time_ms, + frame_score: model.frame_score(), + score: model.score(), + life: model.current_life(), + combo: model.current_combo(), + max_frame_combo, + judgements: counts, + converted_judgements: model.converted_judgements(), + random_draws: model.draws(), + ranges: final_ranges, + bonus_events: bonuses, + frames: trace, + }) + } +} + +fn json(value: &T) -> Result { + serde_json::to_string(value).map_err(|e| Error::Input(format!("replay output JSON: {e}"))) +} diff --git a/tests/chartstats.rs b/tests/chartstats.rs index facd45c..cc64f18 100644 --- a/tests/chartstats.rs +++ b/tests/chartstats.rs @@ -530,8 +530,7 @@ impl Ranked { assist_factor: 1.0, }; let mut lm = - full::LiveModel::new_gekisou_external(master, &deck, &self.notes, &self.events, params, &self.setup) - .unwrap(); + full::LiveModel::new_gekisou_ranked(master, &deck, &self.notes, &self.events, params, &self.setup).unwrap(); for (i, &(rank, pct)) in ranks.iter().enumerate() { lm.queue_gekisou_rank_confirmation(i, rank, pct).unwrap(); } @@ -887,7 +886,7 @@ fn aptitude_shapes_deduplicate_effects_and_abstract_bands() { let h = chartstats::aptitude_header(&d.master, &chartstats::kinds(&d.master), &Default::default()); assert_eq!(h.plain_kind, Some(0)); assert_eq!(h.seed_rule.deterministic_test, 4); - assert_eq!(h.seed_rule.batches, [32, 64, 128, 256, 512, 1024]); + assert_eq!(h.seed_rule.batches, [32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536]); assert_eq!(h.seed_rule.cross_seeds, 64); assert_eq!((h.seed_rule.relative, h.seed_rule.baseline), (0.01, 0.001)); assert_eq!(h.shapes.len(), 8); @@ -1064,6 +1063,8 @@ fn aptitude_seed_statistics_match_paired_full_runs() { assert!(v.check.deck.iter().all(Option::is_none)); let mut scores = Vec::new(); let mut bases = Vec::new(); + let mut perfect_scores = Vec::new(); + let mut perfect_bases = Vec::new(); let mut tails = Vec::new(); for seed in ournotes_deck::live::seeds::published_seeds(v.seeds) { let (base, br) = aptitude_run(&d, None, seed, false); @@ -1071,6 +1072,11 @@ fn aptitude_seed_statistics_match_paired_full_runs() { aptitude_run(&d, Some(Performer { gekisou_skill: Some((3, 2)), ..Default::default() }), seed, false); scores.push(f64::from(with - base)); bases.push(f64::from(base)); + let (base_perfect, _) = aptitude_run(&d, None, seed, true); + let (with_perfect, _) = + aptitude_run(&d, Some(Performer { gekisou_skill: Some((3, 2)), ..Default::default() }), seed, true); + perfect_scores.push(f64::from(with_perfect - base_perfect)); + perfect_bases.push(f64::from(base_perfect)); let range_delta: i32 = wr .iter() .zip(br) @@ -1081,7 +1087,11 @@ fn aptitude_seed_statistics_match_paired_full_runs() { .sum(); tails.push(f64::from(with - base - range_delta)); } - for (reported, actual) in [(v.score, test_mean_se(&scores)), (v.tail, test_mean_se(&tails))] { + for (reported, actual) in [ + (v.score, test_mean_se(&scores)), + (v.score_perfect, test_mean_se(&perfect_scores)), + (v.tail, test_mean_se(&tails)), + ] { for i in 0..2 { assert!((reported[i] - actual[i]).abs() <= 0.000501, "{reported:?} {actual:?}"); } @@ -1090,9 +1100,9 @@ fn aptitude_seed_statistics_match_paired_full_runs() { let [m, se] = test_mean_se(scores); se <= (m.abs() * 0.01).max(test_mean_se(bases)[0] * 0.001) }; - assert_eq!(v.se_target_met, met(&scores, &bases)); + assert_eq!(v.se_target_met, met(&scores, &bases) && met(&perfect_scores, &perfect_bases)); if v.seeds == 64 { - assert!(!met(&scores[..32], &bases[..32])); + assert!(!(met(&scores[..32], &bases[..32]) && met(&perfect_scores[..32], &perfect_bases[..32]))); } assert!( (v.score[0] - v.tail[0] - v.ranges.iter().map(|r| r.range_score[0] + r.rank_bonus[0]).sum::()).abs() diff --git a/tests/gekisou.rs b/tests/gekisou.rs index c844051..fd04cf2 100644 --- a/tests/gekisou.rs +++ b/tests/gekisou.rs @@ -440,3 +440,106 @@ fn snap_gekisou_active_is_a_range_start_event_not_member_skill_state() { let model = run(&master, &deck, [1, 1, 1], |_| 5); assert_eq!(model.current_life(), 1010); } + +/// Network ranking must retain the controller's frame snapshots even while solo ranking +/// later queries the exact chart timestamps. This also prevents a completion-frame rewind. +#[test] +fn external_completion_keeps_controller_score_snapshots_and_current_score() { + let m = master(); + let (notes, events) = chart(); + let (p, dts) = play(¬es, |_| 6); + let setup = GekisouSetup { fevers: vec![(START, END)], missions: vec![3] }; + let mut lm = LiveModel::new_gekisou_external(&m, &[], ¬es, &events, params(¬es), &setup).unwrap(); + let mut start_score = None; + let mut end_score = None; + let mut completed = false; + for (f, &dt) in p.frames.iter().zip(&dts) { + let before_score = lm.score(); + lm.frame_timed(f.time_ms, &f.judged, dt).unwrap(); + let r = lm.gekisou_ranges()[0]; + if r.start_score != 0 && start_score.is_none() { + start_score = Some(r.start_score); + } + if r.end_score != 0 && end_score.is_none() { + end_score = Some(r.end_score); + } + if r.state == 7 && !completed { + completed = true; + assert!(f.judged.is_empty(), "the completion frame has no notes"); + assert_eq!(lm.score(), before_score, "completion must not rewind the current calculator"); + assert_eq!(Some(r.start_score), start_score, "retain the original start snapshot"); + assert_eq!(Some(r.end_score), end_score, "retain the fever-end snapshot"); + } + } + assert!(completed); + let frozen = lm.gekisou_ranges()[0]; + let bonus = ((i64::from(frozen.end_score - frozen.start_score) * 190) / 100) as i32; + let before = lm.score(); + lm.queue_gekisou_rank_confirmation(0, 2, 190).unwrap(); + lm.frame_timed(LENGTH + 100, &[], 0.016).unwrap(); + assert_eq!(lm.gekisou_rank_bonuses(), &[(0, 2, bonus, 190)]); + // AddFixedScore is evaluated by the next score update. + lm.frame_timed(LENGTH + 200, &[], 0.016).unwrap(); + assert_eq!(lm.score(), before + bonus); + assert_eq!(lm.gekisou_ranges()[0].start_score, frozen.start_score); + assert_eq!(lm.gekisou_ranges()[0].end_score, frozen.end_score); +} + +#[test] +fn fixed_rank_solo_adapter_matches_solo_at_rank_one() { + let m = master(); + let (notes, events) = chart(); + let (p, dts) = play(¬es, |_| 6); + let setup = GekisouSetup { fevers: vec![(START, END)], missions: vec![3, 3, 3] }; + let mut solo = LiveModel::new_gekisou(&m, &[], ¬es, &events, params(¬es), &setup).unwrap(); + let mut ranked = LiveModel::new_gekisou_ranked(&m, &[], ¬es, &events, params(¬es), &setup).unwrap(); + ranked.queue_gekisou_rank_confirmation(0, 1, RANK_PCT).unwrap(); + solo.run_timed(&p, &dts).unwrap(); + ranked.run_timed(&p, &dts).unwrap(); + assert_eq!(ranked.trace(), solo.trace()); + assert_eq!(ranked.gekisou_rank_bonuses(), solo.gekisou_rank_bonuses()); + let expected = solo.gekisou_ranges()[0]; + let actual = ranked.gekisou_ranges()[0]; + assert_eq!((actual.start_score, actual.end_score), (expected.start_score, expected.end_score)); +} + +#[test] +fn fixed_rank_solo_adapter_keeps_solo_first_completion_per_frame() { + let m = master(); + let (notes, events) = chart(); + let (p, dts) = play(¬es, |_| 6); + let setup = GekisouSetup { fevers: vec![(START, END); 3], missions: vec![3; 3] }; + let mut solo = LiveModel::new_gekisou(&m, &[], ¬es, &events, params(¬es), &setup).unwrap(); + let mut ranked = LiveModel::new_gekisou_ranked(&m, &[], ¬es, &events, params(¬es), &setup).unwrap(); + for range in 0..3 { + ranked.queue_gekisou_rank_confirmation(range, 1, RANK_PCT).unwrap(); + } + solo.run_timed(&p, &dts).unwrap(); + ranked.run_timed(&p, &dts).unwrap(); + // Native SoloGekisouRankingUpdater selects only the first completion in a frame. + assert_eq!(solo.gekisou_rank_bonuses().len(), 1); + assert_eq!(ranked.gekisou_rank_bonuses(), solo.gekisou_rank_bonuses()); + assert_eq!(ranked.trace(), solo.trace()); +} + +#[test] +fn frame_result_score_retains_pre_ranking_value_while_calculator_rewinds() { + let m = master(); + let (notes, events) = chart(); + let (p, dts) = play(¬es, |_| 6); + let setup = GekisouSetup { fevers: vec![(START, END)], missions: vec![3; 3] }; + let mut solo = LiveModel::new_gekisou(&m, &[], ¬es, &events, params(¬es), &setup).unwrap(); + let mut unranked = LiveModel::new_gekisou_external(&m, &[], ¬es, &events, params(¬es), &setup).unwrap(); + let mut completed = false; + for (frame, &dt) in p.frames.iter().zip(&dts) { + solo.frame_timed(frame.time_ms, &frame.judged, dt).unwrap(); + unranked.frame_timed(frame.time_ms, &frame.judged, dt).unwrap(); + if solo.gekisou_ranges()[0].state == 7 { + completed = true; + assert_eq!(solo.frame_score(), unranked.score()); + assert_ne!(solo.frame_score(), solo.score()); + break; + } + } + assert!(completed); +} diff --git a/tests/livefull.rs b/tests/livefull.rs index 9ee7954..7e555a3 100644 --- a/tests/livefull.rs +++ b/tests/livefull.rs @@ -157,6 +157,44 @@ fn late_judgements_are_rewound() { assert_eq!(late.score(), on_time); } +#[test] +fn unscored_pass_does_not_block_scored_notes_or_member_events() { + let m = master(); + let (notes, events) = chart(); + let deck = vec![performer(Some((1, 1)), &[], 0); 5]; + let input = play(¬es, |_| 5, |_| 0); + let baseline = run(&m, &deck, ¬es, &events, &input); + let mut with_pass = notes.clone(); + with_pass.push(LiveNote { note_id: 122, time_ms: 1500, note_operate_type: 122, judgement_type: 1 }); + let mut input_with_pass = input.clone(); + let frame = input_with_pass.frames.iter_mut().find(|f| f.time_ms >= 1500).unwrap(); + frame.judged.insert(0, JudgedNote { note_id: 122, judgement: 7, judgement_time_ms: 1500 }); + // The simulator-only note must not change the scoring note count. + let mut model = LiveModel::new(&m, &deck, &with_pass, &events, params(¬es)).unwrap(); + model.run(&input_with_pass).unwrap(); + assert_eq!(model.trace(), baseline.trace()); + assert_eq!(model.current_life(), baseline.current_life()); +} + +#[test] +fn unscored_miss_still_causes_damage_and_breaks_combo() { + let m = master(); + let (mut notes, events) = chart(); + notes[20].note_operate_type = 122; + let deck = vec![Performer::default(); 5]; + let input = play(¬es, |n| if n.note_id == 21 { 1 } else { 5 }, |_| 0); + let model = run(&m, &deck, ¬es, &events, &input); + assert_eq!(model.current_life(), 900); + // A zero-percent registered note is a score-command reference, with the same life/combo effects. + let mut reference_tables = tables(); + reference_tables["MasterLiveNoteParameter"] + .as_array_mut() + .unwrap() + .push(json!({"_id": 2, "_noteOperateType": 122, "_scorePercent": 0})); + let reference = run(&master_from(&reference_tables), &deck, ¬es, &events, &input); + assert_eq!(model.trace(), reference.trace()); +} + #[test] fn live_skills_without_rewinds_match_the_batch_score() { let m = master(); diff --git a/tests/raw_bridge.rs b/tests/raw_bridge.rs index 3c785ff..e29528b 100644 --- a/tests/raw_bridge.rs +++ b/tests/raw_bridge.rs @@ -15,11 +15,14 @@ fn model_gekisou(skills: &[i64]) -> LiveModel { build(skills, true) } fn build(skills: &[i64], gekisou: bool) -> LiveModel { + build_with_note_type(skills, gekisou, 1) +} +fn build_with_note_type(skills: &[i64], gekisou: bool, note_operate_type: i32) -> LiveModel { let data: Value = serde_json::from_str(include_str!("fixtures/raw_bridge_master.json")).unwrap(); let tables: Vec<_> = data.as_object().unwrap().iter().map(|(k, v)| (k.clone(), json!({"_allData":v}).to_string())).collect(); let master = Master::from_json_tables(|n| tables.iter().find(|(k, _)| k == n).map(|(_, v)| v.as_str())).unwrap(); - let notes = [LiveNote { note_id: 1, time_ms: 1000, note_operate_type: 1, judgement_type: 1 }]; + let notes = [LiveNote { note_id: 1, time_ms: 1000, note_operate_type, judgement_type: 1 }]; let deck: Vec<_> = skills.iter().map(|&id| Performer { live_skill: Some((id, 1)), ..Default::default() }).collect(); let events: Vec<_> = skills.iter().enumerate().map(|(i, _)| (i as i32, 0)).collect(); let params = LiveParams { @@ -82,6 +85,21 @@ fn milliseconds_limit_runs_after_ft_not_in_submit() { assert!(m.score() > 0); } #[test] +fn unscored_pass_still_consumes_executor_window_callback() { + let mut m = build_with_note_type(&[1], false, 122); + m.frame_raw_timed(0, &[], 0.).unwrap(); + assert_eq!(before(&m, 6), 15); + m.begin_raw_frame(1020, 0.016).unwrap(); + let note = m.submit_raw_judgement(result(7, 20)).unwrap(); + assert_eq!((note.origin, note.judgement), (7, 7)); + assert_eq!(before(&m, 6), 15); + m.finish_raw_frame().unwrap(); + // Skipping AddNoteScore must not filter the event before executor window-limit consumption. + assert_eq!(before(&m, 6), 10); + assert_eq!(m.score(), 0); + assert_eq!(m.current_life(), 1000); +} +#[test] fn just_limit_uses_converted_judgement_and_diff() { let mut m = model_gekisou(&[2, 4]); m.frame_raw_timed(0, &[], 0.).unwrap(); @@ -93,6 +111,7 @@ fn just_limit_uses_converted_judgement_and_diff() { m.finish_raw_frame().unwrap(); assert_eq!(m.raw_runtime().unwrap().just.remaining(), 0); assert_eq!(before(&m, 6), 10); + assert_eq!(m.frame_judgements(), &[(1, 6, 1000)]); } #[test] fn diff_converter_precedes_window_limit_callback() { diff --git a/tests/raw_just_fever.rs b/tests/raw_just_fever.rs new file mode 100644 index 0000000..5d09ecf --- /dev/null +++ b/tests/raw_just_fever.rs @@ -0,0 +1,71 @@ +//! Gekisou's wrapper must toggle Just before raw note grading and percent-window appliers. +use ournotes_deck::live::full::{GekisouSetup, LiveModel, LiveNote, LiveParams, RawJudgementRuntime}; +use ournotes_deck::live::raw::TimingUnit; +use ournotes_deck::live::raw_windows::TimingSet; +use ournotes_deck::master::Master; +use serde_json::{Value, json}; + +fn model(mission: i64, force: bool) -> LiveModel { + let data: Value = serde_json::from_str(include_str!("fixtures/raw_bridge_master.json")).unwrap(); + let tables: Vec<_> = + data.as_object().unwrap().iter().map(|(k, v)| (k.clone(), json!({"_allData":v}).to_string())).collect(); + let master = Master::from_json_tables(|n| tables.iter().find(|(k, _)| k == n).map(|(_, v)| v.as_str())).unwrap(); + let note = LiveNote { note_id: 1, time_ms: 150, note_operate_type: 1, judgement_type: 5 }; + let params = LiveParams { + skill_target_music_type: 0, + total_power: 200000, + music_level: 25, + converted_note_count: 1, + music_length_ms: 1000, + score_music_length_ms: None, + assist_factor: 1.0, + }; + let setup = GekisouSetup { fevers: vec![(100, 200)], missions: vec![mission; 3] }; + let mut model = LiveModel::new_gekisou(&master, &[], &[note], &[], params, &setup).unwrap(); + let mut just = TimingUnit::new(6, 2, 2); + just.enabled = force; + let mut runtime = RawJudgementRuntime::new( + vec![TimingSet { judgement_type: 5, units: vec![just, TimingUnit::new(1, 0, 130)] }], + 0, + 0, + ); + runtime.force_enable_just_judgement = force; + model.enable_raw_runtime(runtime).unwrap(); + model +} + +#[test] +fn just_fever_enables_before_raw_notes_and_percent_miss_cap_then_disables_at_end() { + let mut model = model(3, false); + model.begin_raw_frame(99, 0.016).unwrap(); + assert!(!model.raw_runtime().unwrap().windows.timings[0].units[0].enabled); + model.finish_raw_frame().unwrap(); + model.begin_raw_frame(100, 0.016).unwrap(); + assert!(model.raw_runtime().unwrap().windows.timings[0].units[0].enabled); + let windows = &mut model.raw_runtime_mut().unwrap().windows; + let handles = windows.percent(&[6], 1.0); + // Actual native type 5 / 12: Miss early bound 0 caps the Just early increment to zero. + assert_eq!((windows.timings[0].units[0].before_ms, windows.timings[0].units[0].after_ms), (2, 4)); + windows.disable_percent(&handles); + model.finish_raw_frame().unwrap(); + model.begin_raw_frame(200, 0.016).unwrap(); + assert!(!model.raw_runtime().unwrap().windows.timings[0].units[0].enabled); + model.finish_raw_frame().unwrap(); +} + +#[test] +fn just_end_restores_force_setting_and_combo_ranges_do_not_change_it() { + for mission in [1, 3] { + let mut model = model(mission, true); + model.frame_raw_timed(100, &[], 0.016).unwrap(); + model.begin_raw_frame(200, 0.016).unwrap(); + assert!(model.raw_runtime().unwrap().windows.timings[0].units[0].enabled); + model.finish_raw_frame().unwrap(); + } + let mut combo = model(1, false); + for time in [100, 200] { + combo.begin_raw_frame(time, 0.016).unwrap(); + assert!(!combo.raw_runtime().unwrap().windows.timings[0].units[0].enabled); + combo.finish_raw_frame().unwrap(); + } +} diff --git a/tests/replay.rs b/tests/replay.rs new file mode 100644 index 0000000..6d2984a --- /dev/null +++ b/tests/replay.rs @@ -0,0 +1,218 @@ +//! Public synthetic integration cases for the shared browser/native replay entry point. +use ournotes_deck::data::{DataChart, DeckData}; +use ournotes_deck::live::skip::ChartNote; +use ournotes_deck::master::Master; +use ournotes_deck::replay::{RankConfirmation, ReplayMode, ReplayRawResult, ReplayRawRuntime, ReplaySession}; +use serde_json::{Value, json}; + +fn data() -> DeckData { + let mut rows: Value = serde_json::from_str(include_str!("fixtures/raw_bridge_master.json")).unwrap(); + rows["MasterLiveMusic"] = + json!([{"_id":1,"_easyID":101,"_musicType":1,"_gekisouMission1":1,"_gekisouMission2":2,"_gekisouMission3":3}]); + rows["MasterLiveMusicScore"] = json!([{"_id":101,"_musicScoreLevel":25,"_fullComboCount":3}]); + let tables: Vec<_> = + rows.as_object().unwrap().iter().map(|(k, v)| (k.clone(), json!({"_allData":v}).to_string())).collect(); + let master = Master::from_json_tables(|n| tables.iter().find(|(k, _)| k == n).map(|(_, v)| v.as_str())).unwrap(); + DeckData { + provenance: json!({"replay":{"musicLengthsMs":{"101":2500}}}), + master, + charts: vec![DataChart { + score_id: 101, + asset_key: "synthetic".into(), + asset_sha256: "0".repeat(64), + notes: vec![ + ChartNote { id: 30, time_ms: 1000, note_type: 1 }, + ChartNote { id: 10, time_ms: 1000, note_type: 1 }, + ChartNote { id: 20, time_ms: 1020, note_type: 1 }, + ChartNote { id: 99, time_ms: 1100, note_type: 122 }, + ], + judgement_types: vec![1; 4], + skill_event_ms: vec![0], + fevers: vec![], + }], + } +} + +#[test] +fn template_preserves_enumeration_and_independent_audio_score_lengths() { + let session = ReplaySession::new(data()); + let d = session.describe_chart(101).unwrap(); + assert_eq!(d.music_length_ms, Some(2500)); + assert_eq!(d.score_music_length_ms, 2100); + assert_eq!(d.notes.iter().map(|n| n.note_id).collect::>(), [30, 10, 20, 99]); + assert_eq!(d.notes[3].default_judgement, 7); + for fps in [30, 60, 120] { + let r = session.template(101, 200000, fps).unwrap(); + assert_eq!(r.score_music_length_ms, Some(2100)); + assert_eq!(r.frames[1].delta_seconds.to_bits(), (1.0 / fps as f32).to_bits()); + let f = r.frames.iter().find(|f| f.judgements.iter().any(|j| j.note_id == 30)).unwrap(); + assert_eq!(f.judgements.iter().take(2).map(|j| j.note_id).collect::>(), [30, 10]); + assert!(r.frames.last().unwrap().time_ms >= 4500); + assert_eq!(r.frames.iter().flat_map(|f| &f.judgements).count(), 4); + } + assert!(session.template(101, 200000, 59).is_err()); + let mut missing = data(); + missing.provenance = json!({}); + assert!(ReplaySession::new(missing).template(101, 200000, 60).is_err()); +} + +#[test] +fn complete_mixed_play_counts_actual_results_and_unscored_pass() { + let session = ReplaySession::new(data()); + let mut r = session.template(101, 200000, 60).unwrap(); + r.trace = true; + for j in r.frames.iter_mut().flat_map(|f| &mut f.judgements) { + j.judgement = match j.note_id { + 30 => 4, + 10 => 2, + 20 => 6, + _ => 7, + }; + } + let result = session.run(&r).unwrap(); + assert_eq!( + (result.judgements.great, result.judgements.bad, result.judgements.just, result.judgements.pass), + (1, 1, 1, 1) + ); + assert_eq!(result.life, 900); + assert!(result.score > 0); + assert_eq!(result.frame_score, result.score); + assert_eq!(result.frames.len(), r.frames.len()); + assert_eq!(result.frames.iter().flat_map(|f| f.converted_judgements.iter()).count(), 4); + let again = session.run_json(&serde_json::to_string(&r).unwrap()).unwrap(); + assert_eq!(serde_json::from_str::(&again).unwrap()["score"], result.score); +} + +#[test] +fn invalid_inputs_are_rejected_without_filling_missing_results() { + let session = ReplaySession::new(data()); + let base = session.template(101, 200000, 60).unwrap(); + let mut r = base.clone(); + r.score_id = 999; + assert!(session.run(&r).is_err()); + let mut r = base.clone(); + r.score_music_length_ms = None; + assert!(session.run(&r).is_err()); + let mut r = base.clone(); + r.performers[0].live_skill = Some((999, 1)); + assert!(session.run(&r).is_err()); + let mut r = base.clone(); + r.skill_order[1] = 0; + assert!(session.run(&r).is_err()); + let mut r = base.clone(); + r.frames[1].time_ms = 0; + assert!(session.run(&r).is_err()); + let mut r = base.clone(); + r.frames[1].delta_seconds = f32::NAN; + assert!(session.run(&r).is_err()); + let mut r = base.clone(); + r.frames.iter_mut().flat_map(|f| &mut f.judgements).next().unwrap().note_id = 999; + assert!(session.run(&r).is_err()); + let mut r = base.clone(); + let j = r.frames.iter().flat_map(|f| &f.judgements).next().unwrap().clone(); + r.frames.last_mut().unwrap().judgements.push(j); + assert!(session.run(&r).is_err()); + let mut r = base.clone(); + r.frames.iter_mut().find(|f| !f.judgements.is_empty()).unwrap().judgements.remove(0); + assert!(session.run(&r).is_err()); + r.complete = false; + assert!(session.run(&r).is_ok()); + let mut json = serde_json::to_value(&base).unwrap(); + json["typo"] = json!(1); + assert!(session.run_json(&json.to_string()).is_err()); +} + +#[test] +fn raw_result_bridge_requires_metadata_and_runs_window_callbacks() { + let session = ReplaySession::new(data()); + let mut r = session.template(101, 200000, 60).unwrap(); + r.performers[0].live_skill = Some((1, 1)); // 4000, one-result callback limit. + assert!(session.run(&r).is_err()); // A judged-only stream cannot silently ignore its windows. + r.raw_runtime = Some(ReplayRawRuntime { + just_base_expansion_ms: 50, + original_just_before_ms: 10, + force_enable_just_judgement: false, + }); + assert!(session.run(&r).is_err()); + for j in r.frames.iter_mut().flat_map(|f| &mut f.judgements) { + j.raw_result = Some(ReplayRawResult { + origin: j.judgement, + timing: 2, + origin_diff_ms: 0, + diff_ms: 0, + direction_mismatch: false, + is_easy_flick: false, + }); + } + let result = session.run(&r).unwrap(); + assert_eq!(result.input_kind, "givenRawResultsBeforeSkillConversion"); + assert_eq!((result.judgements.perfect, result.judgements.pass), (3, 1)); + assert_eq!(result.life, 1000); + assert!(result.score > 0); +} + +#[test] +fn three_missions_settle_with_distinct_solo_and_external_contracts() { + let mut d = data(); + d.master.live_musics[0].gekisou_mission_2 = 3; + d.master.live_musics[0].gekisou_mission_3 = 2; + for count in 1..=3 { + for rank in 1..=5 { + d.master.gekisou_ranking_score_bonuses.push( + serde_json::from_value(json!({ + "_id":count*10+rank,"_missionPattern":2,"_count":count,"_rank":rank,"_scoreBonusPercent":100-rank*10 + })) + .unwrap(), + ); + } + } + d.master.gekisou_luck_base_points.push( + serde_json::from_value( + json!({"_id":1,"_noteCategory":0,"_noteSimulateJudgement":5,"_weight":1,"_basePoint":150}), + ) + .unwrap(), + ); + for chance in 0..5 { + d.master.gekisou_luck_bonus_lots.push( + serde_json::from_value(json!({"_id":chance+1,"_chanceLotType":chance,"_lotResult":3,"_weight":1})).unwrap(), + ); + } + d.charts[0].notes[2].time_ms = 1200; + d.charts[0].notes.insert(3, ChartNote { id: 40, time_ms: 1400, note_type: 1 }); + d.charts[0].judgement_types.push(1); + d.charts[0].fevers = vec![(500, 1100), (1150, 1300), (1350, 1500)]; + let session = ReplaySession::new(d); + let mut r = session.template(101, 200000, 60).unwrap(); + for j in r.frames.iter_mut().flat_map(|f| &mut f.judgements) { + if j.note_id == 20 { + j.judgement = 6; + } + } + r.mode = ReplayMode::SoloGekisou; + let solo = session.run(&r).unwrap(); + assert_eq!(solo.ranges.len(), 3); + assert!(solo.ranges.iter().all(|r| r.state == 8 && r.rank_bonus.is_some())); + assert!(solo.ranges[0].max_combo > 0); + assert_eq!(solo.ranges[1].just_count, 1); + assert!(solo.ranges[2].luck_points > 0); + assert_eq!(solo.bonus_events.iter().map(|e| e.rank).collect::>(), [1, 1, 1]); + r.mode = ReplayMode::FixedSoloGekisou { ranks: [1, 1, 1] }; + let fixed = session.run(&r).unwrap(); + assert_eq!((fixed.score, fixed.frame_score), (solo.score, solo.frame_score)); + r.mode = ReplayMode::FixedSoloGekisou { ranks: [5, 3, 2] }; + let fixed = session.run(&r).unwrap(); + assert_eq!(fixed.bonus_events.iter().map(|e| e.rank).collect::>(), [5, 3, 2]); + let frame = r.frames.len() - 1; + r.mode = ReplayMode::ExternalGekisou { + confirmations: vec![ + RankConfirmation { frame, range: 0, rank: 5, percent: 50 }, + RankConfirmation { frame, range: 1, rank: 3, percent: 70 }, + RankConfirmation { frame, range: 2, rank: 2, percent: 80 }, + ], + }; + let external = session.run(&r).unwrap(); + assert_eq!(external.bonus_events.iter().map(|e| e.rank).collect::>(), [5, 3, 2]); + assert!(matches!(external.mode, ReplayMode::ExternalGekisou { .. })); + r.mode = ReplayMode::ExternalGekisou { confirmations: vec![] }; + assert!(session.run(&r).is_err()); // complete cannot hide unsettled external ranks. +} diff --git a/tools/native-validation/.gitignore b/tools/native-validation/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/tools/native-validation/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/tools/native-validation/README.md b/tools/native-validation/README.md new file mode 100644 index 0000000..cf63043 --- /dev/null +++ b/tools/native-validation/README.md @@ -0,0 +1,35 @@ +# Native comparison tools + +This directory contains only resource-independent Python tools. It does not ship client binaries, master data, +chart assets or native captures. Without externally supplied inputs, native validation has not run. + +`make_source_manifest.py` hashes only explicitly supplied `--resource ROLE=PATH` files and records the provided +client/master/model identities. It copies neither their content nor their local paths. `inputs_recorded` is an +identity record, not a test pass; absent resources yield `not_run` and exit code 2. With working-tree changes, +record `--model-dirty` and supply the exact model/driver source or patch as separately hashed resources. + +`compare_native_frames.py` compares ordered JSON frame captures using an explicit contract. `frame` and `timeMs` +are mandatory. Integer values and declared float32 bits must match exactly; wildcard fields include every array +element in order. An extra pool instance is a length difference. A missing requested field is `incomplete` and +cannot pass. Nonfinite float32 patterns cannot pass even if their bits match. A missing capture is `not_run`. Exit codes are 0 for equality within the declared contract, 1 for +differences and 2 for invalid, incomplete or absent input. Its JSON output retains the first and all differences, +input/contract hashes and the coverage qualification. It never shifts frames or applies tolerances. + +Example using explicit external paths: + +```text +python compare_native_frames.py --native NATIVE_CAPTURE.json --rust RUST_CAPTURE.json --contract ordinary-contract.json --output comparison.json +python -m unittest -v test_compare_native_frames.py +``` + +`ordinary-contract.json` specifies the existing model fields used by the heterogeneous ordinary-skill matrix. +Native elapsed/countDecrease fields are excluded explicitly; they are not reconstructed on the Rust side. +Its `arrayLengths` requires all 35 pool instances, so matching empty pools cannot vacuously pass. Other skill +configurations need their own explicit pool length; do not silently reuse the 35-instance contract. +The count of field comparisons describes checks, including identities and inactive pool state; it is not a count +of independent samples or a statistical proof of whole-game equivalence. A model trace must identify its actual +capture phases and input mechanism. Rust consuming native judgements validates downstream arithmetic and state +transitions, not independent touch classification. + +The methodology drafts explain the offline emulator, Unity/libc stand-ins, actual judgement distributions, +coverage gaps, source handling, and the distinction between native network ranking and fixed-rank solo estimates. diff --git a/tools/native-validation/compare_native_frames.py b/tools/native-validation/compare_native_frames.py new file mode 100644 index 0000000..f936050 --- /dev/null +++ b/tools/native-validation/compare_native_frames.py @@ -0,0 +1,121 @@ +"""Exact frame comparison with an explicit field contract and no private resource paths. + +Inputs are externally supplied JSON objects with chartId and ordered frames. The contract +lists integer fields and float32 bit-pattern fields separately. Wildcards address arrays. +An absent input is not_run; a missing requested field is incomplete, never passed. +""" +import argparse +import hashlib +import json +import math +import struct +from pathlib import Path + + +def resolve(value, parts, prefix=''): + if not parts: + return [(prefix, value)] + key, *rest = parts + if key == '*': + if not isinstance(value, list): + raise ValueError(f'{prefix}: expected ordered array') + return [item for index, child in enumerate(value) + for item in resolve(child, rest, f'{prefix}.{index}')] + if not isinstance(value, dict) or key not in value: + raise ValueError(f'{prefix}.{key}: missing requested field') + return resolve(value[key], rest, f'{prefix}.{key}') + + +def float_detail(a, b): + def ordered(x): + return 0xffffffff - x if x & 0x80000000 else x + 0x80000000 + def finite(x): + v = struct.unpack(' JsValue { + JsValue::from_str(&error.to_string()) +} + +#[wasm_bindgen] +impl ReplaySession { + #[wasm_bindgen(constructor)] + pub fn new(deck_data_json: &str) -> Result { + Ok(Self { inner: ournotes_deck::replay::ReplaySession::from_json(deck_data_json).map_err(js_error)? }) + } + pub fn run(&self, request_json: &str) -> Result { + self.inner.run_json(request_json).map_err(js_error) + } + #[wasm_bindgen(js_name = describeChart)] + pub fn describe_chart(&self, score_id: i32) -> Result { + self.inner.describe_chart_json(i64::from(score_id)).map_err(js_error) + } + pub fn template(&self, score_id: i32, power: i32, fps: u32) -> Result { + self.inner.template_json(i64::from(score_id), power, fps).map_err(js_error) + } +}