mod comments;
pub mod parallel;
pub use parallel::{DEFAULT_BATCH, ExportStats, export_parallel, export_range, export_range_from, export_span};
use std::io::{self, Write};
use cbvault_chess::decode::{GameRef, MoveSink, start_as_played, walk_from};
use cbvault_chess::start::{Start, StartCache, start_board_cached};
use cbvault_format::cbh::GameAnnotations;
use cbvault_format::cbh::bytes::NameBuf;
use cbvault_format::cbh::{Entities, GameMoves};
use cbvault_format::error::{Error, Result};
use cbvault_format::game::annotations::{Annotation, GAME_POSITION, timing};
use cbvault_format::game::{GameResult, Head, ROUND_TEXT_BYTES, RecordKind, round_text};
use comments::{At, Bare, Commentary, Notes};
use gigachess::san::{San, check_mate_suffix, move_to_san_body};
use gigachess::{Board, Color, Move};
const NONE: u32 = u32::MAX;
#[derive(Debug)]
struct Node {
san: San,
fullmove: u16,
white: bool,
first_child: u32,
last_child: u32,
next_sibling: u32,
}
#[derive(Debug)]
enum EmitStep {
Line { node: u32, force_number: bool },
Alternatives { main: u32, alt: u32 },
Close,
}
#[derive(Debug, Default)]
struct Tree {
nodes: Vec<Node>,
cur: u32,
parent_of_last: u32,
branches: Vec<u32>,
suffix_pending: bool,
}
impl Tree {
fn new() -> Tree {
Tree {
nodes: vec![Node {
san: San::new(),
fullmove: 0,
white: true,
first_child: NONE,
last_child: NONE,
next_sibling: NONE,
}],
..Default::default()
}
}
fn clear(&mut self) {
self.nodes.truncate(1);
self.nodes[0].first_child = NONE;
self.nodes[0].last_child = NONE;
self.cur = 0;
self.parent_of_last = 0;
self.branches.clear();
}
fn main_line_last(&self) -> Option<u32> {
let mut n = self.nodes.first()?.first_child;
while n != NONE {
let node = self.nodes.get(n as usize)?;
if node.first_child == NONE {
return Some(n - 1);
}
n = node.first_child;
}
None
}
}
impl MoveSink for Tree {
fn play(&mut self, before: &Board, mv: u16, _main: bool) {
let mut san = San::new();
self.suffix_pending = false;
let mv = Move::from_word(mv);
match move_to_san_body(before, mv) {
Some(body) => {
san = body;
self.suffix_pending = !mv.is_null();
}
None => san.push_str("??"),
}
let white = before.turn() == Color::White;
let parent = &self.nodes[self.cur as usize];
let fullmove = if self.cur == 0 {
before.fullmove_number()
} else if white {
parent.fullmove.saturating_add(u16::from(!parent.white))
} else {
parent.fullmove
};
let id = self.nodes.len() as u32;
self.nodes.push(Node {
san,
fullmove,
white,
first_child: NONE,
last_child: NONE,
next_sibling: NONE,
});
let cur = self.cur as usize;
match self.nodes[cur].last_child {
NONE => self.nodes[cur].first_child = id,
last => self.nodes[last as usize].next_sibling = id,
}
self.nodes[cur].last_child = id;
self.parent_of_last = self.cur;
self.cur = id;
}
fn played(&mut self, after: &Board) {
if self.suffix_pending
&& let Some(c) = check_mate_suffix(after)
{
let cur = self.cur as usize;
self.nodes[cur].san.push(c);
}
}
fn branch(&mut self) {
self.branches.push(self.parent_of_last);
}
fn resume(&mut self) {
self.cur = self.branches.pop().unwrap_or(0);
}
fn wants_checkers(&self) -> bool {
true
}
}
#[inline]
fn write_move(out: &mut String, tree: &Tree, n: u32, force_number: bool) {
let node = &tree.nodes[n as usize];
out.reserve(node.san.len() + 8);
if node.white {
push_move_number(out, node.fullmove, false);
} else if force_number {
push_move_number(out, node.fullmove, true);
}
out.push_str(node.san.as_str());
}
#[inline]
fn push_move_number(out: &mut String, n: u16, black: bool) {
push_u16(out, n);
if black {
out.push('.');
out.push('.');
}
out.push('.');
out.push(' ');
}
#[inline]
fn push_u16(out: &mut String, n: u16) {
let mut digits = [0u8; 5];
let mut i = digits.len();
let mut n = u32::from(n);
loop {
i -= 1;
digits[i] = b'0' + (n % 10) as u8;
n /= 10;
if n == 0 {
break;
}
}
push_ascii(out, &digits[i..]);
}
#[inline]
fn push_u32(out: &mut String, n: u32) {
let mut digits = [0u8; 10];
let mut i = digits.len();
let mut n = n;
loop {
i -= 1;
digits[i] = b'0' + (n % 10) as u8;
n /= 10;
if n == 0 {
break;
}
}
push_ascii(out, &digits[i..]);
}
#[inline]
fn push_ascii(out: &mut String, bytes: &[u8]) {
out.reserve(bytes.len());
for &b in bytes {
out.push(char::from(b));
}
}
fn emit(tree: &Tree, notes: &mut impl Notes, out: &mut String, steps: &mut Vec<EmitStep>) -> Result<()> {
use EmitStep::*;
let mut write = |out: &mut String, n: u32, force_number: bool| -> Result<()> {
let at = At { stored: n - 1 };
notes.before(at, out)?;
write_move(out, tree, n, force_number);
notes.after(at, out)?;
out.push(' ');
Ok(())
};
let nodes = &tree.nodes;
steps.clear();
steps.push(EmitStep::Line { node: 0, force_number: true });
while let Some(step) = steps.pop() {
match step {
Line { node, force_number } => {
let main = nodes[node as usize].first_child;
if main == NONE {
continue;
}
write(out, main, force_number)?;
match nodes[main as usize].next_sibling {
NONE => steps.push(Line { node: main, force_number: false }),
alt => steps.push(Alternatives { main, alt }),
}
}
Alternatives { main, alt } => {
if alt == NONE {
steps.push(Line { node: main, force_number: true });
} else {
steps.push(Alternatives { main, alt: nodes[alt as usize].next_sibling });
steps.push(Close);
out.push('(');
write(out, alt, true)?;
steps.push(Line { node: alt, force_number: false });
}
}
Close => {
if out.ends_with(' ') {
out.pop();
}
out.push_str(") ");
}
}
}
Ok(())
}
fn write_tags<H: Head>(
out: &mut String,
header: &H,
entities: &Entities,
start: &Start,
board: &Board,
names: &mut Names,
) -> Result<()> {
let id = |v: i64| -> Option<u32> { (v >= 0).then_some(v as u32) };
let (event, place) = match id(header.tournament())
.map(|id| entities.tournament_into(id, &mut names.title, &mut names.place))
.transpose()?
.flatten()
{
Some((_start, title, place)) => {
(if title.is_empty() { "?" } else { title }, if place.is_empty() { "?" } else { place })
}
None => ("?", "?"),
};
push_escaped_tag(out, "[Event \"", event);
push_escaped_tag(out, "[Site \"", place);
push_tag(out, "[Date \"", str_from_bytes(&header.played_date().text()));
push_round(out, header.round(), names);
let white = id(header.white())
.map(|id| entities.player_into(id, &mut names.white_last, &mut names.white_first))
.transpose()?
.flatten();
push_escaped_player(out, "[White \"", white);
let black = id(header.black())
.map(|id| entities.player_into(id, &mut names.black_last, &mut names.black_first))
.transpose()?
.flatten();
push_escaped_player(out, "[Black \"", black);
push_tag(out, "[Result \"", result_tag(header.result()));
if let Some(eco) = header.eco().code_text() {
push_ascii_tag(out, "[ECO \"", &eco);
}
let (white_elo, black_elo) = header.elo();
if white_elo > 0 {
push_number_tag(out, "[WhiteElo \"", white_elo as u32);
}
if black_elo > 0 {
push_number_tag(out, "[BlackElo \"", black_elo as u32);
}
if *start != Start::Standard {
if matches!(start, Start::Chess960(_)) || matches!(start, Start::Setup(s) if s.chess960) {
push_tag(out, "[Variant \"", "Chess960");
}
push_tag(out, "[SetUp \"", "1");
let fen = fen_tag(board, start);
push_tag(out, "[FEN \"", &fen);
}
out.push('\n');
Ok(())
}
#[derive(Debug, Default)]
struct Names {
white_last: NameBuf,
white_first: NameBuf,
black_last: NameBuf,
black_first: NameBuf,
title: NameBuf,
place: NameBuf,
round: [u8; ROUND_TEXT_BYTES],
}
#[inline]
fn push_tag(out: &mut String, head: &str, value: &str) {
out.push_str(head);
out.push_str(value);
out.push('"');
out.push(']');
out.push('\n');
}
#[inline]
fn push_escaped_tag(out: &mut String, head: &str, value: &str) {
out.push_str(head);
escape_into(out, value);
out.push('"');
out.push(']');
out.push('\n');
}
#[inline]
fn push_ascii_tag(out: &mut String, head: &str, value: &[u8]) {
out.push_str(head);
push_ascii(out, value);
out.push('"');
out.push(']');
out.push('\n');
}
#[inline]
fn push_number_tag(out: &mut String, head: &str, value: u32) {
out.push_str(head);
push_u32(out, value);
out.push('"');
out.push(']');
out.push('\n');
}
#[inline]
fn push_escaped_player(out: &mut String, head: &str, player: Option<(&str, &str)>) {
out.push_str(head);
match player {
Some((last, first)) => {
escape_into(out, last);
if !first.is_empty() {
out.push_str(", ");
escape_into(out, first);
if (1..=2).contains(&first.len()) && first.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
{
out.push('.');
}
} else if last.is_empty() {
out.push('?');
}
}
None => out.push('?'),
}
out.push('"');
out.push(']');
out.push('\n');
}
#[inline]
fn str_from_bytes(bytes: &[u8; 10]) -> &str {
std::str::from_utf8(bytes).unwrap_or("????.??.??")
}
fn push_round(out: &mut String, (round, sub): (i32, i32), names: &mut Names) {
out.push_str("[Round \"");
if round <= 0 && sub > 0 {
out.push_str("?.");
push_u32(out, sub as u32);
} else {
let text = round_text(round, sub, &mut names.round);
if text.is_empty() {
out.push('?');
} else {
match (text.find('('), text.find(')')) {
(Some(open), Some(close)) => {
out.push_str(&text[..open]);
out.push('.');
out.push_str(&text[open + 1..close]);
}
_ => out.push_str(text),
}
}
}
out.push('"');
out.push(']');
out.push('\n');
}
fn fen_tag(board: &Board, start: &Start) -> String {
let fen = board.to_fen();
match start {
Start::Setup(setup) if setup.move_number == 0 && fen.ends_with(" 1") => format!("{}0", &fen[..fen.len() - 1]),
_ => fen,
}
}
fn escape_into(out: &mut String, text: &str) {
if !text.bytes().any(|b| b == b'"' || b == b'\\' || b == 0xee) {
out.push_str(text);
return;
}
for c in text.chars() {
if let Some(letter) = figurine(c) {
out.push(letter);
continue;
}
if c == '"' || c == '\\' {
out.push('\\');
}
out.push(c);
}
}
fn figurine(c: char) -> Option<char> {
match c {
'\u{e024}' => Some('K'),
'\u{e025}' => Some('Q'),
'\u{e026}' => Some('R'),
'\u{e027}' => Some('B'),
'\u{e028}' => Some('N'),
'\u{e029}' => Some('P'),
_ => None,
}
}
pub fn result_tag(result: GameResult) -> &'static str {
match result {
GameResult::BothLost => "*",
other => other.pgn(),
}
}
fn as_io(e: Error) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, e)
}
#[derive(Debug, Default)]
pub struct PgnWriter {
tree: Tree,
game: String,
index: Vec<(i32, u32)>,
evp: String,
parts: comments::Parts,
names: Names,
starts: StartCache,
steps: Vec<EmitStep>,
}
impl PgnWriter {
pub fn new() -> PgnWriter {
PgnWriter { tree: Tree::new(), ..Default::default() }
}
pub fn capacity(&self) -> usize {
self.game.capacity()
+ self.tree.nodes.capacity() * size_of::<Node>()
+ self.index.capacity() * size_of::<(i32, u32)>()
+ self.evp.capacity()
+ self.parts.capacity()
+ std::mem::size_of::<Names>()
+ self.starts.len() * std::mem::size_of::<(Start, Board)>()
}
pub fn write_game<H: Head>(
&mut self,
out: &mut impl Write,
header: &H,
entities: &Entities,
game: &GameMoves<'_>,
anns: Option<&GameAnnotations<'_>>,
) -> io::Result<()> {
if header.kind() != RecordKind::Game {
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("record {} is not a game", header.id())));
}
let what = GameRef::new(header.id());
let start = start_as_played(what, game).map_err(as_io)?;
let board = start_board_cached(&start, &mut self.starts).map_err(as_io)?;
self.tree.clear();
self.game.clear();
write_tags(&mut self.game, header, entities, &start, &board, &mut self.names).map_err(as_io)?;
let stats = walk_from(what, game, &start, &mut self.tree).map_err(as_io)?;
let anns = anns.filter(|a| !a.is_empty());
match anns {
None => emit(&self.tree, &mut Bare, &mut self.game, &mut self.steps).map_err(as_io)?,
Some(a) => {
a.check_positions(stats.total_plies).map_err(as_io)?;
self.prepare(a).map_err(as_io)?;
let mut notes = Commentary {
anns: a,
index: &self.index,
evp: &self.evp,
parts: &mut self.parts,
moves: stats.total_plies,
last: self.tree.main_line_last(),
};
notes.game_comment(&mut self.game).map_err(as_io)?;
emit(&self.tree, &mut notes, &mut self.game, &mut self.steps).map_err(as_io)?;
}
}
for b in result_tag(header.result()).as_bytes() {
self.game.push(char::from(*b));
}
self.game.push('\n');
self.game.push('\n');
out.write_all(self.game.as_bytes())
}
fn prepare(&mut self, a: &GameAnnotations<'_>) -> Result<()> {
self.index.clear();
self.evp.clear();
let mut ascending = true;
let mut prev = i32::MIN;
let mut evp = false;
for item in a.iter() {
let item = item?;
ascending &= item.position >= prev;
prev = item.position;
self.index.push((item.position, item.offset));
if let Annotation::Other { code: 0x26, data } = item.annotation
&& !evp
&& item.position == GAME_POSITION
{
if let Some(entries) = timing::evaluations(data, true) {
comments::write_evp(&mut self.evp, &entries);
evp = !self.evp.is_empty();
}
}
}
if !ascending {
self.index.sort_by_key(|&(p, _)| p);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt::Write as _;
#[test]
fn number_writers_match_the_formatting_machinery() {
for n in [0u16, 1, 9, 10, 99, 100, 999, 9999, u16::MAX] {
let mut want = String::new();
let _ = write!(want, "{n}");
let mut got = String::new();
push_u16(&mut got, n);
assert_eq!(got, want, "u16 {n}");
let mut want = String::new();
let _ = write!(want, "{n}... ");
let mut got = String::new();
push_move_number(&mut got, n, true);
assert_eq!(got, want, "black {n}");
let mut want = String::new();
let _ = write!(want, "{n}. ");
let mut got = String::new();
push_move_number(&mut got, n, false);
assert_eq!(got, want, "white {n}");
}
for n in [0u32, 7, 10, 4096, 32768, u32::from(u16::MAX), u32::MAX] {
let mut want = String::new();
let _ = write!(want, "{n}");
let mut got = String::new();
push_u32(&mut got, n);
assert_eq!(got, want, "u32 {n}");
}
}
#[test]
fn tag_writers_match_the_formatting_machinery() {
let cases = [
("Paris", "FRA"),
("a\\\"b", "c\\\\d"),
("Odds \u{e025}d1", ""),
("\u{201e}Zitat\u{201c}", "M\u{fc}nchen"),
("", ""),
];
for (value, _) in cases {
let mut want = String::new();
let _ = writeln!(want, r#"[Event "{}"]"#, escape_old(value));
let mut got = String::new();
push_escaped_tag(&mut got, "[Event \"", value);
assert_eq!(got, want, "Event {value:?}");
}
for (last, first) in [("Morphy", ""), ("Delaire", "H."), ("Ward", "JH"), ("Keres", "Paul"), ("", "")] {
let name = player_name_old(last, first);
let mut want = String::new();
let _ = writeln!(want, r#"[White "{}"]"#, escape_old(&name));
let mut got = String::new();
push_escaped_player(&mut got, "[White \"", Some((last, first)));
assert_eq!(got, want, "player {last:?} {first:?}");
}
let mut got = String::new();
push_escaped_player(&mut got, "[White \"", None);
assert_eq!(got, "[White \"?\"]\n");
for n in [0u32, 1, 2404, 32767] {
let mut want = String::new();
let _ = writeln!(want, r#"[WhiteElo "{n}"]"#);
let mut got = String::new();
push_number_tag(&mut got, "[WhiteElo \"", n);
assert_eq!(got, want, "elo {n}");
}
}
#[test]
fn round_tag_matches_the_old_writer() {
for (round, sub) in [(0, 0), (0, 4), (5, 0), (5, 2), (-1, 0), (1, 1), (0, 12)] {
let mut want = String::new();
let text = round_tag_old(round, sub);
let _ = writeln!(want, r#"[Round "{}"]"#, if text.is_empty() { "?" } else { &text });
let mut names = Names::default();
let mut got = String::new();
push_round(&mut got, (round, sub), &mut names);
assert_eq!(got, want, "round {round}.{sub}");
}
}
fn escape_old(text: &str) -> String {
let has_figurine = text.chars().any(|c| figurine(c).is_some());
if !text.contains(['"', '\\']) && !has_figurine {
return text.to_owned();
}
let mut out = String::with_capacity(text.len() + 2);
for c in text.chars() {
if let Some(letter) = figurine(c) {
out.push(letter);
continue;
}
if c == '"' || c == '\\' {
out.push('\\');
}
out.push(c);
}
out
}
fn player_name_old(last: &str, first: &str) -> String {
if first.is_empty() {
return if last.is_empty() { "?".to_owned() } else { last.to_owned() };
}
let initials =
(1..=2).contains(&first.len()) && first.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit());
format!("{last}, {first}{}", if initials { "." } else { "" })
}
fn round_tag_old(round: i32, sub: i32) -> String {
if round <= 0 && sub > 0 {
return format!("?.{sub}");
}
let mut buf = [0u8; ROUND_TEXT_BYTES];
let text = round_text(round, sub, &mut buf);
match (text.find('('), text.find(')')) {
(Some(open), Some(close)) => format!("{}.{}", &text[..open], &text[open + 1..close]),
_ => text.to_owned(),
}
}
}