Changelog

Every ticket done, by release and then by area, with the engine's breaking changes first. Written from tickets/done/ and the merges by cargo xtask changelog.

2026-11 — 2026-11-07

Breaking changes

Migration guide

A cast's voices carry the voice tool's settings, so Cast takes the settings' type and each voice is a CastVoice.

Since 0.1.0: Cast { voices: BTreeMap<String, String> } → Cast<S> { voices: BTreeMap<String, CastVoice<S>> } (A voice cast a voice tool can use: each speaker's voice, the tool's own settings, and a voice per clip variant).

Before:

let voice: &String = &cast.voices["guide"];

After:

let voice: &String = &cast.voice_for("guide", variant).unwrap().voice;

A cast file gives each speaker a voice and the tool's settings, and its keys may name a clip variant.

Since 0.1.0: cast_from_source(file, source, can_speak) -> Result<Cast, _> → cast_from_source::<S, V>(file, source, can_speak) -> Result<Cast<S>, _> (A voice cast a voice tool can use: each speaker's voice, the tool's own settings, and a voice per clip variant).

Before:

let cast = voice::cast_from_source("cast.ron", &text, can_speak)?;
// cast.ron: (voices: { "guide": "guide_a" })

After:

let cast: Cast<()> = voice::cast_from_source::<(), Voice>("cast.ron", &text, can_speak)?;
// cast.ron: (voices: { "guide": (voice: "guide_a", settings: ()) })

A language pack's rules say how many cells each character takes, so its limits count a wide character as two.

Since 0.1.0: PackRules { data_problem, tokens, limits, variants } → PackRules { data_problem, tokens, limits, widths, variants } (The text checks measure with the font atlas's wide glyphs, and a reloaded atlas changes the frame's widths).

Before:

const RULES: PackRules<'static> = PackRules {
    data_problem,
    tokens: &NoTokens,
    limits: LIMITS,
    variants: &[],
};

After:

let widths = CellWidths::of(&atlas);
let rules = PackRules {
    data_problem,
    tokens: &NoTokens,
    limits: LIMITS,
    widths: &widths,
    variants: &[],
};

A tip's rules say how many cells each character takes, so its title and lines count a wide character as two.

Since 0.1.0: TipRules { limits, placeholder, face_problem, default_face } → TipRules { limits, widths, placeholder, face_problem, default_face } (The text checks measure with the font atlas's wide glyphs, and a reloaded atlas changes the frame's widths).

Before:

let rules = TipRules {
    limits,
    placeholder: &placeholder,
    face_problem: &face,
    default_face: "calm",
};

After:

let widths = CellWidths::of(&atlas);
let rules = TipRules {
    limits,
    widths: &widths,
    placeholder: &placeholder,
    face_problem: &face,
    default_face: "calm",
};

The text measures count a wide character of the font atlas as two cells.

Since 0.1.0: longest_width(text, tokens, longest_name); lines_at_longest(text, tokens, longest_name, width); Names::longest() → longest_width(text, tokens, longest_name, widths); lines_at_longest(text, tokens, longest_name, width, widths); Names::longest(widths) (The text checks measure with the font atlas's wide glyphs, and a reloaded atlas changes the frame's widths).

Before:

let longest = names.longest();
let cells = longest_width(text, &tokens, Some(longest));

After:

let widths = CellWidths::of(&atlas);
let longest = names.longest(&widths);
let cells = longest_width(text, &tokens, Some(longest), &widths);

A font atlas may hold wide glyphs; one built in code says it has none.

Since 0.1.0: FontAtlasDef { cell_w, cell_h, columns, glyphs } → FontAtlasDef { cell_w, cell_h, columns, glyphs, wide } (Wide glyphs: a character two cells wide in the glyph buffer, drawn from a wide font in the atlas).

Before:

let atlas = FontAtlasDef { cell_w: 8, cell_h: 16, columns: 32, glyphs };

After:

let atlas = FontAtlasDef { cell_w: 8, cell_h: 16, columns: 32, glyphs, wide: BTreeMap::new() };

text_width counts a wide character as two cells, so it takes the widths it measures with.

Since 0.1.0: lang::text_width(&str) → lang::text_width(&str, &CellWidths) (Wide glyphs: a character two cells wide in the glyph buffer, drawn from a wide font in the atlas).

Before:

let w = text_width(label);

After:

let w = text_width(label, buf.widths());

cut measures text in cells, a wide character two.

Since 0.1.0: cut(&str, usize) → cut(&str, usize, &CellWidths) (Wide glyphs: a character two cells wide in the glyph buffer, drawn from a wide font in the atlas).

Before:

let shown = cut(name, 12);

After:

let shown = cut(name, 12, buf.widths());

A pack baked before this engine is refused; bake it again.

Since 0.1.0: pack::VERSION 7 → pack::VERSION 8 (Wide glyphs: a character two cells wide in the glyph buffer, drawn from a wide font in the atlas).

Before:

cargo shuyi bake

After:

cargo shuyi bake

font-atlas may read a wide font; a caller without one passes None.

Since 0.1.0: font_atlas::run(&rules, command, &fonts, out_dir, &chars) → font_atlas::run(&rules, command, &fonts, None, out_dir, &chars) (Wide glyphs: a character two cells wide in the glyph buffer, drawn from a wide font in the atlas).

Before:

font_atlas::run(&rules, "shuyi", &fonts, out, &chars)

After:

font_atlas::run(&rules, "shuyi", &fonts, None, out, &chars)

word_wrap measures in cells and takes the width table, so a wide character counts as two and Japanese breaks between characters.

Since 0.1.0: word_wrap(text: &str, width: usize) -> Vec<String> → word_wrap(text: &str, width: usize, widths: &CellWidths) -> Vec<String> (Line breaking for text without spaces (Japanese), and a report of translated text too long for its place).

Before:

let lines = word_wrap(&text, 30);

After:

let lines = word_wrap(&text, 30, ctx.widths());

A text box wraps its text in cells, so it takes the width table.

Since 0.1.0: TextBox::new(text: &str, width: usize, per_page: usize) → TextBox::new(text: &str, width: usize, per_page: usize, widths: &CellWidths) (Line breaking for text without spaces (Japanese), and a report of translated text too long for its place).

Before:

let text = TextBox::new(&line, WIDTH, LINES);

After:

let text = TextBox::new(&line, WIDTH, LINES, ctx.widths());

lang-status's report measures screen text against its places, so it takes the width table.

Since 0.1.0: lang_status::report(lang, variants, code, options) → lang_status::report(lang, variants, widths, code, options) (Line breaking for text without spaces (Japanese), and a report of translated text too long for its place).

Before:

let said = report(&lang, &variants, "ja", &Options::default())?;

After:

let said = report(&lang, &variants, &CellWidths::NARROW, "ja", &Options::default())?;

A font atlas has named wide sets beside its unnamed one.

Since 0.1.0: FontAtlasDef { cell_w, cell_h, columns, glyphs, wide } → FontAtlasDef { cell_w, cell_h, columns, glyphs, wide, wide_sets } (Wide glyph sets per language: one character drawn by language, punctuation wide in one language only, and Chinese line breaking).

Before:

FontAtlasDef { cell_w: 8, cell_h: 16, columns: 32, glyphs, wide: BTreeMap::new() }

After:

FontAtlasDef { cell_w: 8, cell_h: 16, columns: 32, glyphs, wide: BTreeMap::new(), wide_sets: BTreeMap::new() }

A pack's lang.ron may name its wide set and make its punctuation wide.

Since 0.1.0: LangInfo { name, made_by } → LangInfo { name, made_by, wide_font, wide_punctuation } (Wide glyph sets per language: one character drawn by language, punctuation wide in one language only, and Chinese line breaking).

Before:

LangInfo { name, made_by: MadeBy::Machine }

After:

LangInfo { name, made_by: MadeBy::Machine, wide_font: None, wide_punctuation: false }

A pack baked before this engine is refused; bake it again.

Since 0.1.0: pack::VERSION 8 → pack::VERSION 9 (Wide glyph sets per language: one character drawn by language, punctuation wide in one language only, and Chinese line breaking).

Before:

cargo shuyi bake

After:

cargo shuyi bake

font-atlas takes several wide fonts, each a set.

Since 0.1.0: font_atlas::run(rules, command, fonts, Option<&Path>, out, chars) → font_atlas::run(rules, command, fonts, &[WideFont], out, chars) (Wide glyph sets per language: one character drawn by language, punctuation wide in one language only, and Chinese line breaking).

Before:

font_atlas::run(rules, "shuyi", &fonts, Some(&wide), &out, &chars)

After:

font_atlas::run(rules, "shuyi", &fonts, &[WideFont::parse(wide)], &out, &chars)

build_atlas and run take the characters the atlas keeps; AtlasChars::default() is the ranges alone, as before.

Since 0.1.0: font_atlas::build_atlas(&BitmapFont) → font_atlas::build_atlas(&BitmapFont, &AtlasChars) (The font atlas keeps the characters the text uses, and shuyi check fails on text it can't draw).

Before:

let atlas = build_atlas(&font);

After:

let atlas = build_atlas(&font, &AtlasChars::default());

A glyph tileset is a plain Image, and its pixel outside the picture is None.

Since 0.2.0: glyph_tiles::Tileset, Tileset::pixel(x, y) -> [u8; 4] → shuyi_assets::image::Image, Image::pixel(x, y) -> Option<[u8; 4]> (One RGBA picture type: the raster, glyph-tiles and font-atlas use shuyi_assets::image::Image).

Before:

let tiles: Tileset = glyph_tiles::render_files(&rules, root)?;
let p = tiles.pixel(x, y);

After:

let tiles: Image = glyph_tiles::render_files(&rules, root)?;
let p = tiles.pixel(x, y).unwrap_or([0; 4]);

glyph_tiles::render takes the font atlas's picture as an Image.

Since 0.2.0: render(rules, &def, (width, &rgba), &palette) → render(rules, &def, &atlas_image, &palette) (One RGBA picture type: the raster, glyph-tiles and font-atlas use shuyi_assets::image::Image).

Before:

glyph_tiles::render(&rules, &atlas.def, (atlas.width, &atlas.rgba), &palette)

After:

glyph_tiles::render(&rules, &atlas.def, &atlas.image, &palette)

A font atlas keeps its picture as one Image field.

Since 0.2.0: atlas.width, atlas.height, atlas.rgba; one_bit(&def, width, &rgba) → atlas.image.width, atlas.image.height, atlas.image.rgba; one_bit(&def, &image) (One RGBA picture type: the raster, glyph-tiles and font-atlas use shuyi_assets::image::Image).

Before:

let png = encode_png(atlas.width, atlas.height, &atlas.rgba)?;
let bits = one_bit(&atlas.def, atlas.width, &atlas.rgba)?;

After:

let png = encode_png(atlas.image.width, atlas.image.height, &atlas.image.rgba)?;
let bits = one_bit(&atlas.def, &atlas.image)?;

The raster's picture of a painted picture is a free function.

Since 0.2.0: raster::Image::of_painted(&picture) → raster::of_painted(&picture) (One RGBA picture type: the raster, glyph-tiles and font-atlas use shuyi_assets::image::Image).

Before:

let image = raster::Image::of_painted(&picture);

After:

let image = raster::of_painted(&picture);

Item has a new variant for pictures a skin paints, and more may come.

Since 0.2.0: match item { Item::Rect(o) => .., Item::Sprite(s) => .. } → match item { Item::Rect(o) => .., Item::Sprite(s) => .., _ => .. } (Pictures a skin paints at run time: RGBA, indexed and one-bit pixels in the frame, uploaded only when they change, and dithered on a one-bit target).

Before:

match item {
    Item::Rect(o) => draw_rect(o),
    Item::Sprite(s) => draw_sprite(s),
}

After:

match item {
    Item::Rect(o) => draw_rect(o),
    Item::Sprite(s) => draw_sprite(s),
    Item::Painted(p) => draw_painted(p),
    _ => {}
}

On a Playdate, help text names East A and West B, the positions its profile lists for those buttons.

Since 0.1.0: PadKind::Playdate.button_name(Button::South) is "A", and Button::East is "B" → PadKind::Playdate.button_name(Button::East) is "A", and Button::West is "B" (PadKind::Playdate follows the Playdate profile: A is East and B is West in its docs, its button names and its tests).

Before:

"Confirm": ["South"],
"Cancel": ["East"],

After:

"Confirm": ["East"],
"Cancel": ["West"],

Start is non-exhaustive and gains made and state_hash, so it is built with Start::new.

Since 0.1.0: Start { card, load, look, on_system } → Start::new(card, look, load).on_system(hook) (Session records: every input, clock reading, music position, keyboard answer and stored file that reaches a game, recorded at the platform boundary and played back exactly on the headless backend, with a hash per frame).

Before:

let start = Start { card, load: Box::new(load), look, on_system: Some(hook) };

After:

let start = Start::new(card, look, load).on_system(hook);

The answers of scene and of MCP's start say how far a session record played.

Since 0.1.0: SceneOutput { frames, top_screen, scene, log } → SceneOutput { frames, top_screen, scene, log, session } (Session records from real builds: desktop debug builds keep their last sessions, a debug menu entry saves one (a download on the web), and shuyi run, scene and MCP play them back).

Before:

let out = SceneOutput { frames: 0, top_screen: String::new(), scene: None, log: Vec::new() };

After:

let out = SceneOutput { frames: 0, top_screen: String::new(), scene: None, log: Vec::new(), session: None };

Hello, Grid's keymap gained Debug, so two rows of its golden table moved.

Since 0.1.0: the example's table with no Debug action in its keymap → the example's table with F2 bound to Debug (Session records from real builds: desktop debug builds keep their last sessions, a debug menu entry saves one (a download on the web), and shuyi run, scene and MCP play them back).

Before:

"asset keymap.ron": "950f9516da8a766d",

After:

"asset keymap.ron": "79cefbc7be17411e",

The bots operation is folded into playtest; a game's bot run is a playtest plan with one entrant and no checks.

Since 0.1.0: GameTools::bots(&self, input: &BotsInput) -> Result<BotsOutput, String> → GameTools::playtest_plan(&self, stage: Option<&str>, input: &PlaytestInput) -> Result<PlaytestPlan, String> (One operation plays a game's bots: bots folded into playtest, which answers each try's commands and record for replay).

Before:

fn bots(&self, input: &BotsInput) -> Result<BotsOutput, String> {
    let plays = (input.seed..input.seed + u64::from(input.games))
        .map(|seed| bot_play(&map, seed, input.turns))
        .collect::<Result<_, _>>()?;
    Ok(tally(plays))
}

After:

fn playtest_plan(&self, _: Option<&str>, _: &PlaytestInput) -> Result<PlaytestPlan, String> {
    let map = self.map()?;
    Ok(PlaytestPlan::new(
        "the stage, played by a random bot",
        vec![("random".to_owned(), None)],
        Box::new(|_| Vec::new()),
        Box::new(move |_, seed, turns| bot_try(&map, seed, turns)),
    ))
}

A plan's one try gives the commands applied and its record besides the try.

Since 0.1.0: Box<dyn Fn(usize, u64, u32) -> Result<Tried, String>> → Box<dyn Fn(usize, u64, u32) -> Result<PlayedTry, String>> (One operation plays a game's bots: bots folded into playtest, which answers each try's commands and record for replay).

Before:

Box::new(move |entrant, seed, turns| {
    let mut tries = play_tries(&stage, &ENTRANTS[entrant], seed..seed + 1, turns)
        .map_err(|e| e.to_string())?;
    tries.pop().ok_or_else(|| "no try".to_owned())
})

After:

Box::new(move |entrant, seed, turns| {
    play_try(&stage, &ENTRANTS[entrant], seed, turns).map_err(|e| e.to_string())
})

The contract's script is shuyi-platform's, and a step can press an action by name.

Since 0.2.0: Script { steps: vec![Step::Keys(..)] } → Script::new(vec![Step::Keys(..)]) (One input script: the steps the Harness, the contract and the live link play, in shuyi-platform, with actions by name).

Before:

let script = Script { steps: vec![Step::Keys("Enter".to_owned())] };

After:

let script = Script::new(vec![Step::Act("Confirm".to_owned())]);

A script flag is read by a free function, and the Harness's longest wait is checked with the script.

Since 0.2.0: Script::parse_step(flag, text); script.check() → frame_png::parse_step(flag, text); frame_png::check(&script) (One input script: the steps the Harness, the contract and the live link play, in shuyi-platform, with actions by name).

Before:

if let Some(step) = Script::parse_step(arg, &text) { steps.push(step?); }
script.check()?;

After:

if let Some(step) = frame_png::parse_step(arg, &text) { steps.push(step?); }
frame_png::check(&script)?;

attach takes the contract's steps.

Since 0.2.0: AttachStep::Act(name) → Step::Act(name) (One input script: the steps the Harness, the contract and the live link play, in shuyi-platform, with actions by name).

Before:

input.steps = vec![AttachStep::Act("Confirm".into()), AttachStep::Wait(0.5)];

After:

input.steps = vec![Step::Act("Confirm".into()), Step::Wait(0.5)];

Since 0.2.0: link::Step::Wait(30) → script::Step::Wait(0.5) (One input script: the steps the Harness, the contract and the live link play, in shuyi-platform, with actions by name).

Before:

let steps = vec![link::Step::Act("Confirm".into()), link::Step::Wait(30)];

After:

let steps = vec![script::Step::Act("Confirm".into()), script::Step::Wait(0.5)];

A capture plan's frames are planned with the game's keymap.

Since 0.2.0: plan.frames() → plan.frames(game.input().keymap())? (One input script: the steps the Harness, the contract and the live link play, in shuyi-platform, with actions by name).

Before:

let frames = plan.frames();

After:

let frames = plan.frames(game.input().keymap())?;

A script's wait plays whole frames, rounded up.

Since 0.2.0: Step::Wait(0.51) as 30.6 frames → Step::Wait(0.51) as 31 frames (One input script: the steps the Harness, the contract and the live link play, in shuyi-platform, with actions by name).

Before:

Script::new(vec![Step::Wait(0.51)]).play(&mut h);

After:

Script::new(vec![Step::Wait(31.0 / 60.0)]).play(&mut h);

A capitalized placeholder in en.ron must be an action or {Cursor}; a value a screen passes is lowercase.

Since 0.1.0: "key_bindings.help_switch": "{CursorLeft}/{CursorRight} keyboard or controller" → "key_bindings.help_switch": "{Left}/{Right} keyboard or controller" (A misspelled action in a language file fails shuyi check, rather than showing its braces to the player).

Before:

"key_bindings.help_switch": "{CursorLeft}/{CursorRight} keyboard or controller · {Cursor} move · {Cancel} back",

After:

"key_bindings.help_switch": "{Left}/{Right} keyboard or controller · {Cursor} move · {Cancel} back",

SceneOutput can't be built with a struct literal outside shuyi-cli any more; it grew value and shape.

Since 0.1.0: SceneOutput { frames, top_screen, scene, log, session } → run::scene_output(session) (Scene data derived from the game's state, and one value or its shape read by pointer through the contract).

Before:

let output = SceneOutput { frames, top_screen, scene, log, session: None };

After:

let (output, limits) = shuyi_cli::contract::run::scene_output(&*session);

EventsOutput has a new field, sounds_added, the sounds the game added at run time.

Since 0.1.0: EventsOutput { frames, events, sounds } → EventsOutput { frames, events, sounds, sounds_added } (The contract's answers list the sounds a game added at run time).

Before:

EventsOutput { frames, events, sounds }

After:

EventsOutput { frames, events, sounds, sounds_added: Vec::new() }

A decoder of shuyi-sound owns or borrows its bytes, as its type says.

Since 0.1.0: WavSource<'a>, VorbisSource<'a> → WavSource<B: AsRef<[u8]>>, VorbisSource<B: AsRef<[u8]>> (Gated sound output on Windows through CPAL and the web through our own Web Audio bridge).

Before:

fn first(source: &mut WavSource<'_>) {}

After:

fn first(source: &mut WavSource<&[u8]>) {}

Polling the controllers takes whether the window has focus, and ignores the pads without it.

Since 0.2.0: PadInput::poll(&mut self) → PadInput::poll(&mut self, focused: bool) (Focus on the desktop and the web: shuyi-app sends FocusLost and FocusGained, held keys and pads are let go on focus loss and suspend, and a game may pause or mute in the background).

Before:

let events = pads.poll();

After:

let events = pads.poll(true);

ExportCx has two new fields; ExportOutput has one.

Since 0.2.0: ExportCx { .. } → ExportCx { turned_on, beside, .. } (shuyi export builds a game's named features and ships the files it names beside the program).

Before:

ExportCx { features, .. }

After:

ExportCx { features, turned_on: Vec::new(), beside: Vec::new(), .. }

A speech or narration step has a direction.

Since 0.2.0: Step::Narrate { text, line } → Step::Narrate { text, direction, line } (A dialogue line carries a delivery direction, never shown, and a voice clip remembers it).

Before:

Step::Narrate { text: "Rain.".into(), line: LineId::default() }

After:

Step::Narrate { text: "Rain.".into(), direction: None, line: LineId::default() }

A line that Scene::lines lists has a direction.

Since 0.2.0: Line { id, speaker, text } → Line { id, speaker, text, direction } (A dialogue line carries a delivery direction, never shown, and a voice clip remembers it).

Before:

Line { id, speaker, text }

After:

Line { id, speaker, text, direction: None }

A voice clip records the direction it was made under.

Since 0.2.0: VoiceClip { line, variant, spoken, voice, made_by } → VoiceClip { line, variant, spoken, direction, voice, made_by } (A dialogue line carries a delivery direction, never shown, and a voice clip remembers it).

Before:

VoiceClip { line, variant, spoken, voice, made_by }

After:

VoiceClip { line, variant, spoken, direction: None, voice, made_by }

@delivery is the engine's, and packs baked before this change are refused.

Since 0.2.0: @delivery as a game's own directive; pack version 6 → @delivery <direction> and @delivery clear; pack version 7 (A dialogue line carries a delivery direction, never shown, and a voice clip remembers it).

Before:

shuyi bake

After:

shuyi bake

Whether voices play is a player setting, saved with the others.

Since 0.2.0: Ctx::voices_on → Settings::voices (Voices on or off and their volume are player settings).

Before:

ctx.voices_on = false;

After:

ctx.change_settings(|s| s.voices = false)?;

The voice volume is a player setting from 0 to 100, like the music's.

Since 0.2.0: Ctx::voice_volume (0–10), Ctx::voice_gain → Settings::voice_volume (0–100), Settings::voice_factor (Voices on or off and their volume are player settings).

Before:

ctx.voice_volume = DEFAULT_VOICE_VOLUME;
let gain = ctx.voice_gain();

After:

ctx.change_settings(|s| s.voice_volume = DEFAULT_VOLUME)?;
let gain = ctx.settings().voice_factor();

Settings has the voices' two fields.

Since 0.2.0: Settings { text_speed, fullscreen, theme, music_volume, sound_volume, frame_cap, game, .. } → Settings { .., voices, voice_volume } (Voices on or off and their volume are player settings).

Before:

Settings { text_speed, fullscreen, theme, music_volume, sound_volume, frame_cap, game, .. }

After:

Settings { text_speed, fullscreen, theme, music_volume, sound_volume, voices: true, voice_volume: 80, frame_cap, game, .. }

TipLimits has a new field, how_lines, the most lines of a tip's how.

Since 0.2.0: TipLimits { text_lines, line_chars, title_chars } → TipLimits { text_lines, line_chars, title_chars, how_lines } (A tip keeps its instructions apart from its text).

Before:

TipLimits { text_lines: 3, line_chars: 60, title_chars: 30 }

After:

TipLimits { text_lines: 3, line_chars: 60, title_chars: 30, how_lines: 0 }

Tip has a new field, how, the tip's instructions apart from its text.

Since 0.2.0: Tip { id, trigger, title, text, face } → Tip { id, trigger, title, text, how, face } (A tip keeps its instructions apart from its text).

Before:

Tip { id, trigger, title, text, face: None }

After:

Tip { id, trigger, title, text, how: None, face: None }

Repository and foundation

Core and simulation

Assets and formats

Frame, screens and skins

Input

Tools and AI support

Docs and community

Research

Export targets

3D and HD-2D

Other tickets

Other changes

2026-10 — 2026-10-31

Breaking changes

Migration guide

RegisterError can now grow, so a match on it ends with a wildcard arm.

Since 0.1.0: match error { RegisterError::Extension { .. } => a, RegisterError::Id { .. } => b, RegisterError::BadExtension { .. } => c } → match error { RegisterError::Extension { .. } => a, RegisterError::Id { .. } => b, _ => c } (Import settings beside any file and per folder: a .meta file of typed sections that formats and bake steps read, named so no RON format claims it).

Before:

match error {
    RegisterError::Extension { .. } => "extension",
    RegisterError::Id { .. } => "id",
    RegisterError::BadExtension { .. } => "bad extension",
}

After:

match error {
    RegisterError::Extension { .. } => "extension",
    RegisterError::Id { .. } => "id",
    _ => "other",
}

A request for music carries how to switch to it, and is built with a constructor.

Since 0.1.0: AudioRequest::PlayMusic { cue } → AudioRequest::play_music(cue, switch) (Music crossfades and fade-ins: how one track gives way to the next, per request, with today's fade-out-then-start as the default).

Before:

let theme = AudioRequest::PlayMusic { cue: "theme".into() };
if let AudioRequest::PlayMusic { cue } = request {}

After:

let theme = AudioRequest::play_music("theme", None);
if let AudioRequest::PlayMusic { cue, .. } = request {}

A track's Start says the gain it starts at (0 for one that fades in).

Since 0.1.0: MusicCommand::Start { cue } → MusicCommand::Start { cue, gain, .. }, MusicCommand::start(cue, gain) (Music crossfades and fade-ins: how one track gives way to the next, per request, with today's fade-out-then-start as the default).

Before:

MusicCommand::Start { cue } => player.start(cue, 1.0),

After:

MusicCommand::Start { cue, gain, .. } => player.start(cue, *gain),

The audio manifest has a default fade-in and overlap, and is built with default() and assignments.

Since 0.1.0: AudioManifest { music_fade_ms: 500, ..AudioManifest::default() } → AudioManifest::default(), then assign music_fade_ms (Music crossfades and fade-ins: how one track gives way to the next, per request, with today's fade-out-then-start as the default).

Before:

let mut audio = AudioManifest {
    music_fade_ms: 500,
    ..AudioManifest::default()
};

After:

let mut audio = AudioManifest::default();
audio.music_fade_ms = 500;

Packs are at version 4; one baked before is refused until it is baked again.

Since 0.1.0: pack::VERSION 3 → pack::VERSION 4 (Music crossfades and fade-ins: how one track gives way to the next, per request, with today's fade-out-then-start as the default).

Before:

// a pack baked by an engine before this one

After:

// the same pack, baked again with `shuyi bake`

An audio manifest's state hash changed with its two new fields.

Since 0.1.0: "audio manifest: audio-manifest": 51a06dc742cd9913 → "audio manifest: audio-manifest": 7bca5ef50c2dd0fe (Music crossfades and fade-ins: how one track gives way to the next, per request, with today's fade-out-then-start as the default).

Before:

// the game's golden table, as blessed before

After:

// the same table after `cargo xtask golden --bless`

An audio request may be a sound started, stopped or changed by its handle, and the enum may grow.

Since 0.1.0: match request { AudioRequest::PlaySound { .. } => .., AudioRequest::StopVoice => .., /* every variant */ } → match request { AudioRequest::PlaySound { .. } => .., _ => .. } (Looped sounds with handles: start, stop, fade and set the volume by id, sounds a screen owns, paused under a pause menu, and audio held while the system suspends the game).

Before:

match request {
    AudioRequest::PlaySound { cue, .. } => play(cue),
    AudioRequest::PlayMusic { .. } | AudioRequest::StopMusic => {}
    AudioRequest::PlayVoice { .. } | AudioRequest::StopVoice | AudioRequest::PreloadVoices { .. } => {}
}

After:

match request {
    AudioRequest::PlaySound { cue, .. } => play(cue),
    _ => {}
}

A sound request may start, stop or change a sound by its handle, and the enum may grow.

Since 0.1.0: match request { SoundRequest::Sound { .. } => .., SoundRequest::Voice { .. } => .., /* every variant */ } → match request { SoundRequest::Sound { .. } => .., SoundRequest::Start { id, cue, volume, .. } => .., _ => .. } (Looped sounds with handles: start, stop, fade and set the volume by id, sounds a screen owns, paused under a pause menu, and audio held while the system suspends the game).

Before:

match request {
    SoundRequest::Sound { cue, volume } => play(cue, *volume),
    SoundRequest::Voice { clip } => say(clip),
    SoundRequest::StopVoice | SoundRequest::PreloadVoices { .. } => {}
}

After:

match request {
    SoundRequest::Sound { cue, volume } => play(cue, *volume),
    SoundRequest::Voice { clip } => say(clip),
    SoundRequest::Start { id, cue, volume, .. } => start(*id, cue, *volume),
    SoundRequest::Stop { id } => stop(*id),
    _ => {}
}

Packs are at version 5; one baked before is refused until it is baked again.

Since 0.1.0: pack::VERSION 4 → pack::VERSION 5 (Looped sounds with handles: start, stop, fade and set the volume by id, sounds a screen owns, paused under a pause menu, and audio held while the system suspends the game).

Before:

// a pack baked by an engine before this one

After:

// the same pack, baked again with `shuyi bake`

An audio manifest's state hash changed with the sound cues' new field.

Since 0.1.0: "audio manifest: audio-manifest": 7bca5ef50c2dd0fe → "audio manifest: audio-manifest": 0bcf9f8817da21e0 (Looped sounds with handles: start, stop, fade and set the volume by id, sounds a screen owns, paused under a pause menu, and audio held while the system suspends the game).

Before:

// the game's golden table, as blessed before

After:

// the same table after `cargo xtask golden --bless`

The sound device holds its audio while the system suspends the game, so every backend implements Audio::suspend and Audio::resume.

Since 0.1.0: impl Audio for MyDevice { fn music_playing(..); fn play(..); fn stop_all(..); } → impl Audio for MyDevice { fn music_playing(..); fn play(..); fn stop_all(..); fn suspend(&mut self); fn resume(&mut self); } (Sounds paused under a pause menu, and the audio held while the system suspends the game).

Before:

impl Audio for MyDevice {
    fn music_playing(&self, now: Millis) -> Option<(&str, f64)> { self.music(now) }
    fn play(&mut self, frame: &AudioFrame<'_>, now: Millis) { self.mix(frame, now) }
    fn stop_all(&mut self) { self.silence() }
}

After:

impl Audio for MyDevice {
    fn music_playing(&self, now: Millis) -> Option<(&str, f64)> { self.music(now) }
    fn play(&mut self, frame: &AudioFrame<'_>, now: Millis) { self.mix(frame, now) }
    fn stop_all(&mut self) { self.silence() }
    fn suspend(&mut self) { self.mute_all() }
    fn resume(&mut self) { self.unmute_all() }
}

The contract has a new operation, export, so Op has a new variant.

Since 0.1.0: match op { Op::New => …, … } → match op { Op::New => …, Op::Export => …, … } (shuyi export <target>: one command that checks a game and packages it for a target, starting with Windows, Linux, macOS and the web).

Before:

match op {
    Op::Attach => attach(),
    // … every other operation
}

After:

match op {
    Op::Attach => attach(),
    Op::Export => export(),
    // … every other operation
}

A baked file says where its source was read from, and can grow.

Since 0.1.0: BakedFile { path, kinds, source } → BakedFile { path, shown, kinds, source } (non-exhaustive) (1-bit sprites at bake time: Atkinson or Bayer dithering per image, tested equal to the source, and per-target asset layers for hand-drawn overrides).

Before:

let BakedFile { path, kinds, source } = file;

After:

let BakedFile { path, kinds, source, .. } = file;

The engine's standard formats have the onebit section.

Since 0.1.0: formats.register_import_settings::<MyOneBit>() → cx.import_settings::<shuyi_assets::bitmap::dither::OneBitSettings>(file) (1-bit sprites at bake time: Atkinson or Bayer dithering per image, tested equal to the source, and per-target asset layers for hand-drawn overrides).

Before:

let mut formats = Formats::standard();
formats.register_import_settings::<MyOneBit>()?;

After:

let formats = Formats::standard();

bake's input and answer grew, and are built from their default.

Since 0.1.0: BakeInput { target, out } → BakeInput::default(), then its fields (1-bit sprites at bake time: Atkinson or Bayer dithering per image, tested equal to the source, and per-target asset layers for hand-drawn overrides).

Before:

let input = BakeInput { target: Some("playdate".into()), out: None };

After:

let mut input = BakeInput::default();
input.target = Some("playdate".into());

A crank's notch is 45 degrees unless the keymap file says, where it was 30.

Since 0.2.0: a keymap file with no crank part: a notch every 30 degrees → a keymap file with no crank part: a notch every 45 degrees (shuyi-playdate: the Playdate backend (update callback, d-pad, A, B and crank, 1-bit display with changed rows, storage) and shuyi export playdate).

Before:

let crank = CrankDef::default(); // 30 degrees

After:

let crank = CrankDef { notch_degrees: 30 };

ExportCx has two more public fields, app and features.

Since 0.2.0: ExportCx { .. } → ExportCx { app, features, .. } (shuyi-playdate: the Playdate backend (update callback, d-pad, A, B and crank, 1-bit display with changed rows, storage) and shuyi export playdate).

Before:

ExportCx { web: None }

After:

ExportCx { web: None, app: None, features: Vec::new() }

Look holds a one-bit look with a &dyn Images, so it no longer crosses threads or a catch_unwind.

Since 0.2.0: std::panic::catch_unwind(|| paint(look)) → paint(look) (shuyi-playdate: the Playdate backend (update callback, d-pad, A, B and crank, 1-bit display with changed rows, storage) and shuyi export playdate).

Before:

let _ = std::panic::catch_unwind(|| paint(look));

After:

paint(look);

The Playdate's SDK trait has the sound calls, which an implementation outside the engine must add.

Since 0.1.0: impl Sdk for MySdk { ... } → impl Sdk for MySdk { type Sample = ...; type SamplePlayer = ...; type FilePlayer = ...; fn sample_load(...) ... } (The audio player's rules shared by every backend, and audio on the Playdate: sounds baked to ADPCM, music streamed through the SDK).

Before:

impl Sdk for MySdk {
    fn millis(&self) -> u32 { 0 }
}

After:

impl Sdk for MySdk {
    type Sample = usize;
    type SamplePlayer = usize;
    type FilePlayer = usize;
    fn millis(&self) -> u32 { 0 }
    fn sample_load(&self, path: &str) -> Option<usize> { None }
}

The Playdate exporter's folders have the build's own folder.

Since 0.1.0: Dirs { source, obj } → Dirs { source, obj, build } (The audio player's rules shared by every backend, and audio on the Playdate: sounds baked to ADPCM, music streamed through the SDK).

Before:

let dirs = Dirs { source, obj };

After:

let dirs = Dirs { source, obj, build };

shuyi-cli's Config and TargetCheck are

Since 0.1.0: Config { command: Some(..), ..Config::default() }; match check { TargetCheck::Fits => .., TargetCheck::Text => .., TargetCheck::Controls => .., TargetCheck::OneBit => .. } → Config::parse(text) or Config::default() with fields assigned; a match with a _ => arm (A game's identity in shuyi.ron: its name, a reverse-DNS id, version, build, publisher and one icon, checked by check --target, and an AppInfo the game's binary reads).

Before:

let config = Config { command: Some("cargo shuyi".to_owned()), ..Config::default() };

After:

let mut config = Config::default();
config.command = Some("cargo shuyi".to_owned());

BasicGame::new and BasicGame::with_own take who the game is, an AppInfo, in place of its name and its storage's name.

Since 0.1.0: BasicGame::with_own(name: &'static str, storage: &'static str, files, own, first) → BasicGame::with_own(app: AppInfo, files, own, first) (A game's identity in shuyi.ron: its name, a reverse-DNS id, version, build, publisher and one icon, checked by check --target, and an AppInfo the game's binary reads).

Before:

const NAME: &str = "Moon Garden";
const STORAGE_NAME: &str = "moon-garden";
BasicGame::with_own(NAME, STORAGE_NAME, files, stages::own, first)

After:

const APP: AppInfo = AppInfo::new("Moon Garden", "com.example.moon-garden", env!("CARGO_PKG_VERSION"))
    .with_storage("moon-garden");
BasicGame::with_own(APP, files, stages::own, first)

The context's keyboard moved into the platform's facts.

Since 0.1.0: Ctx::keyboard → Ctx::platform.keyboard (Platform facts at run time: the target's id, whether a game can quit, whether its window can go full screen, and its keyboard, pointer and touch, read from the target profile and handed through Setup into Ctx).

Before:

ctx.keyboard = Keyboard::Absent;

After:

ctx.platform.keyboard = Keyboard::Absent;

A backend says its platform's facts, and the setup carries them.

Since 0.1.0: Setup::keyboard → Setup::platform.keyboard, and Platform::facts (Platform facts at run time: the target's id, whether a game can quit, whether its window can go full screen, and its keyboard, pointer and touch, read from the target profile and handed through Setup into Ctx).

Before:

start.before_load(|setup| setup.keyboard = Keyboard::Absent)

After:

start.before_load(|setup| setup.platform.keyboard = Keyboard::Absent)

A target profile says how the player quits and whether its display is a window.

Since 0.1.0: Display { sizes, color, min_text_px, good_text_px, safe_area } → Display { sizes, color, min_text_px, good_text_px, safe_area, windowed } (Platform facts at run time: the target's id, whether a game can quit, whether its window can go full screen, and its keyboard, pointer and touch, read from the target profile and handed through Setup into Ctx).

Before:

Display { sizes, color, min_text_px, good_text_px, safe_area }

After:

Display { sizes, color, min_text_px, good_text_px, safe_area, windowed: false }

The pack format is version 6; a pack baked before is refused.

Since 0.1.0: pack::VERSION 5 → pack::VERSION 6 (Platform facts at run time: the target's id, whether a game can quit, whether its window can go full screen, and its keyboard, pointer and touch, read from the target profile and handed through Setup into Ctx).

Before:

shuyi bake   # a pack of version 5

After:

shuyi bake   # bake it again: version 6

The golden state hash of a decoded target profile moved with its two new fields.

Since 0.1.0: formats.ron "target profile: target-ron": a51804880890f80b → formats.ron "target profile: target-ron": 9dbfa252bb4b827b (Platform facts at run time: the target's id, whether a game can quit, whether its window can go full screen, and its keyboard, pointer and touch, read from the target profile and handed through Setup into Ctx).

Before:

a51804880890f80b

After:

9dbfa252bb4b827b

shuyi_app::Icon is now a re-export of shuyi_platform::WindowIcon, the window's icon that AppInfo carries; its fields and constructor are the same.

Since 0.1.0: shuyi_app::Icon (a struct of shuyi-app) → shuyi_app::Icon = shuyi_platform::WindowIcon (The game's icon in shuyi.ron: one square PNG checked by check --target, downscaled in integers by app-info, and the window's icon from AppInfo).

Before:

const WINDOW: Window = Window::new("A game", 640, 480).with_icon(Some(Icon::new(SMALL, MEDIUM, BIG)));

After:

const WINDOW: Window = Window::new("A game", 640, 480).with_icon(Some(Icon::new(SMALL, MEDIUM, BIG)));

shuyi_cli::app_info::files reads the icon from the project's root, and fails when it doesn't load.

Since 0.1.0: app_info::files(part: &AppPart, command: &str) -> Vec<(String, Vec<u8>)> → app_info::files(part: &AppPart, root: &Path, command: &str) -> Result<Vec<(String, Vec<u8>)>, String> (The game's icon in shuyi.ron: one square PNG checked by check --target, downscaled in integers by app-info, and the window's icon from AppInfo).

Before:

let files = app_info::files(&part, "shuyi");

After:

let files = app_info::files(&part, &root, "shuyi")?;

Carved from the game

Repository and foundation

Core and simulation

Assets and formats

Frame, screens and skins

Tools and AI support

Game styles and examples

Docs and community

Research

Export targets

Other changes