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
- shuyi-assets: voice::Cast: it takes the tool's settings type,
Cast<S>;voicesholdsCastVoice<S>(.voiceis the old string), and it is#[non_exhaustive], built withCast::newordefault(). (A voice cast a voice tool can use: each speaker's voice, the tool's own settings, and a voice per clip variant (#291)) - shuyi-assets: voice::cast_from_source: it is generic over the tool's settings
Sand the game'sClipVariantV, and each entry in the file is(voice: "..", settings: ..)instead of a bare string. (A voice cast a voice tool can use: each speaker's voice, the tool's own settings, and a voice per clip variant (#291)) - shuyi-assets: lang::PackRules: a new field
widths: &CellWidths; a game that builds one addswidths: &CellWidths::NARROW, or its atlas's&CellWidths::of(&atlas). (The text checks measure with the font atlas's wide glyphs, and a reloaded atlas changes the frame's widths (#267)) - shuyi-assets: tip::TipRules: a new field
widths: &CellWidths; a game that builds one adds it, the atlas's widths or&CellWidths::NARROW. (The text checks measure with the font atlas's wide glyphs, and a reloaded atlas changes the frame's widths (#267)) - shuyi-assets: dialogue::longest_width, dialogue::lines_at_longest, names::Names::longest: each takes a last argument
widths: &CellWidths; a game that calls them passes its atlas's widths, or&CellWidths::NARROWfor one cell a character. (The text checks measure with the font atlas's wide glyphs, and a reloaded atlas changes the frame's widths (#267)) - shuyi-assets:
font::FontAtlasDef: new fieldwide; a game that builds one addswide: BTreeMap::new(). (Wide glyphs: a character two cells wide in the glyph buffer, drawn from a wide font in the atlas (#264)) - shuyi-assets:
lang::text_width: takes the widths; pass the frame's (buf.widths()), the context's (ctx.widths()) or&CellWidths::NARROW. (Wide glyphs: a character two cells wide in the glyph buffer, drawn from a wide font in the atlas (#264)) - shuyi-ui:
frame::draw::cut(andscreens::cut): takes the widths; passbuf.widths(). (Wide glyphs: a character two cells wide in the glyph buffer, drawn from a wide font in the atlas (#264)) - shuyi-assets:
pack::VERSIONandformats.ron"font atlas: font-atlas": a font atlas's binary form haswide, and a baked pack is of version 8; every game bakes again. (Wide glyphs: a character two cells wide in the glyph buffer, drawn from a wide font in the atlas (#264)) - shuyi-cli:
font_atlas::runtakes the wide font's path after the fonts' (Nonefor none), andproject::FontAtlashaswide_glyphs. (Wide glyphs: a character two cells wide in the glyph buffer, drawn from a wide font in the atlas (#264)) - shuyi-assets, shuyi-ui:
text::word_wrap(re-exported asui::widgets::word_wrap): takes the width table; a game passesctx.widths()(or&CellWidths::NARROW). (Line breaking for text without spaces (Japanese), and a report of translated text too long for its place (#271)) - shuyi-ui:
dialogue::text_box::TextBox::new: takes the width table; a game passesctx.widths(). (Line breaking for text without spaces (Japanese), and a report of translated text too long for its place (#271)) - shuyi-cli:
lang_status::report: takes the width table after the variants; a caller passes the atlas's (CellWidths::of) or&CellWidths::NARROW. (Line breaking for text without spaces (Japanese), and a report of translated text too long for its place (#271)) - shuyi-assets:
font::FontAtlasDef: a struct literal addswide_sets. (Wide glyph sets per language: one character drawn by language, punctuation wide in one language only, and Chinese line breaking (#283)) - shuyi-assets:
lang::LangInfo: a struct literal addswide_fontandwide_punctuation. (Wide glyph sets per language: one character drawn by language, punctuation wide in one language only, and Chinese line breaking (#283)) - shuyi-assets:
pack::VERSION,formats.ron"font atlas: font-atlas" and "language pack: lang-pack", and hello-grid's "asset font/atlas.ron": a font atlas's and a pack's info's binary forms have the new fields, and a baked pack is of version 9; every game bakes again. (Wide glyph sets per language: one character drawn by language, punctuation wide in one language only, and Chinese line breaking (#283)) - shuyi-cli:
font_atlas::runtakes the wide fonts as&[WideFont],AtlasChars::wants_widetakes the set, andproject::FontAtlashaswide_set_glyphs. (Wide glyph sets per language: one character drawn by language, punctuation wide in one language only, and Chinese line breaking (#283)) - shuyi-cli: font_atlas::build_atlas and font_atlas::run: each takes an
&AtlasCharslast; pass&AtlasChars::default()for the atlas of before. (The font atlas keeps the characters the text uses, and shuyi check fails on text it can't draw (#256)) - shuyi-cli: glyph_tiles::Tileset: gone;
glyph_tiles::renderandrender_filesreturnshuyi_assets::image::Image, whosepixelgives anOption. (One RGBA picture type: the raster, glyph-tiles and font-atlas use shuyi_assets::image::Image (#295)) - shuyi-cli: glyph_tiles::render: takes the atlas picture as
&Image, not a(width, &rgba)pair. (One RGBA picture type: the raster, glyph-tiles and font-atlas use shuyi_assets::image::Image (#295)) - shuyi-cli: font_atlas::Atlas, font_atlas::one_bit: the atlas's picture is its
imagefield, anImage, andone_bittakes it as&Image. (One RGBA picture type: the raster, glyph-tiles and font-atlas use shuyi_assets::image::Image (#295)) - shuyi-ui, shuyi-cli: frame::raster::Image::of_painted: a free function,
raster::of_painted, sinceImageis nowshuyi_assets::image::Image. (One RGBA picture type: the raster, glyph-tiles and font-atlas use shuyi_assets::image::Image (#295)) - shuyi-ui: frame::glyph_buffer::Item: a new variant,
Painted, and the enum is#[non_exhaustive]: amatchonItemoutside the engine adds a_arm. (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 (#274)) - shuyi-ui: PadKind::button_name: on PadKind::Playdate, East is named A, West B and South X; a front end that reports A at South and B at East moves to East and West (PadKind::Playdate follows the Playdate profile: A is East and B is West in its docs, its button names and its tests (#215))
- shuyi-platform: Start: it is
#[non_exhaustive]with two more fields (made,state_hash), so a game builds it withStart::newand its builders, never by literal (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 (#234)) - shuyi-cli:
contract::run::SceneOutputandmcp::StartOutput: gainsession; a struct literal of either addssession: None. (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 (#239)) - hello-grid: tests/golden/hello-grid.ron "asset keymap.ron" and "fingerprint files": the keymap has a Debug action; only the example's table moves, and a game changes nothing. (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 (#239))
- shuyi-cli: GameTools::bots, contract::bots (BotsInput, BotsOutput, Play, Spread, tally, UNFINISHED, MAX_GAMES, parse_args, usage, text, game_arg, options_text) and the
botsoperation and MCP tool: gone; a game gives its bot run as aPlaytestPlanwith one entrant and no checks (GameTools::playtest_plan), and reads each try'scommandsandrecordfromplaytest's answer. (One operation plays a game's bots:botsfolded intoplaytest, which answers each try's commands and record forreplay(#297)) - shuyi-cli: PlayTry, StagePlay, Evaluate: a plan's one try returns
shuyi_core::bots::playtest::PlayedTry(the try, the commands applied, its record), notTried; most simplyplay_tryin place ofplay_triesover one seed. (One operation plays a game's bots:botsfolded intoplaytest, which answers each try's commands and record forreplay(#297)) - shuyi-cli: frame_png::Step, frame_png::Script: re-exports of
shuyi_platform::script::{Step, Script};StepgainsAct, andScriptis built withScript::new(its fields can't be named from another crate). (One input script: the steps the Harness, the contract and the live link play, in shuyi-platform, with actions by name (#273)) - shuyi-cli: frame_png::Script::parse_step, frame_png::MAX_WAIT: now
frame_png::parse_step(flag, text), which reads--acttoo, andshuyi_platform::harness::MAX_WAIT;Script::checkno longer checks the longest wait:frame_png::check(&script)does. (One input script: the steps the Harness, the contract and the live link play, in shuyi-platform, with actions by name (#273)) - shuyi-cli: contract::attach::AttachStep, AttachStep::of, frames_of, FRAMES_PER_SECOND: gone;
AttachInput::stepsandlink_stepsareVec<frame_png::Step>, waits in seconds. (One input script: the steps the Harness, the contract and the live link play, in shuyi-platform, with actions by name (#273)) - shuyi-platform: link::Step, link::Press, link::press_for, link::MAX_WAIT, link::VERSION: the steps are
script::Step, waits in seconds (link::MAX_WAITis 1 800.0 seconds),press_forandPressarescript's,Requestis no longerEq, and the link speaks version 3. (One input script: the steps the Harness, the contract and the live link play, in shuyi-platform, with actions by name (#273)) - shuyi-platform: capture::Step, capture::Plan::steps, capture::Plan::frames, capture::FRAME_DT, capture::wait_frames: a plan's steps are
capture::PlanSteps (Input(script::Step)orCapture),Plan::framestakes the game's keymap and answers aResult, and the frame and wait rule arescript::FRAME_DTandscript::wait_frames. (One input script: the steps the Harness, the contract and the live link play, in shuyi-platform, with actions by name (#273)) - shuyi-cli, shuyi-platform: a script's wait: played as
script::wait_framesframes (rounded up, none for 0) byScript::play, not byHarness::wait's shorter last frame, so aframe-pngpicture after a wait that isn't whole frames, or of 0, can change;Harness::waitkeeps its seconds. (One input script: the steps the Harness, the contract and the live link play, in shuyi-platform, with actions by name (#273)) - shuyi-ui:
BasicData::load: a game'sen.ronwith a capitalized placeholder that is neither an action of itsActionSetnor{Cursor}now fails to load, andchecknames it (SH2008); fix the name, or write a value the screen passes in lowercase. Hello, Grid's golden "asset en.ron" and "fingerprint files" rows moved with its text. (A misspelled action in a language file failsshuyi check, rather than showing its braces to the player (#304)) - shuyi-cli: contract::run::SceneOutput: it is
#[non_exhaustive]and has two more fields,valueandshape; a game reads it fromscene_outputor an answer instead of building it with a struct literal (Scene data derived from the game's state, and one value or its shape read by pointer through the contract (#306)) - shuyi-cli: mcp::EventsOutput: a new public field,
sounds_added; a literal of it adds it. (The contract's answers list the sounds a game added at run time (#298)) - shuyi-sound:
wav::WavSourceandvorbis::VorbisSource: their lifetime is now a type, what they read: nothing to change where a game callsnewwith a slice, andWavSource<'a>written out becomesWavSource<&'a [u8]>(Gated sound output on Windows through CPAL and the web through our own Web Audio bridge (#240)) - shuyi-app: PadInput::poll: takes whether the window has focus. (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 (#268))
- shuyi-cli: export::ExportCx: two new public fields,
turned_onandbeside; a literal of it adds them. (shuyi export builds a game's named features and ships the files it names beside the program (#212)) - shuyi-assets:
dialogue::Step::Say,dialogue::Step::Narrate: new fielddirection; a game that builds or fully matches one adds it. (A dialogue line carries a delivery direction, never shown, and a voice clip remembers it (#238)) - shuyi-assets:
dialogue::Line: new fielddirection; a game that builds one adds it. (A dialogue line carries a delivery direction, never shown, and a voice clip remembers it (#238)) - shuyi-assets:
voice::VoiceClip: new fielddirection; a game that builds one adds it. (A dialogue line carries a delivery direction, never shown, and a voice clip remembers it (#238)) - shuyi-assets:
.dlgformat andpack::VERSION: a speech line's head may hold(direction)and@deliveryis the engine's, and a baked pack is of version 7; a game with its own@deliverydirective renames it, and every game bakes again. (A dialogue line carries a delivery direction, never shown, and a voice clip remembers it (#238)) - shuyi-ui: Ctx::voices_on: gone; read and set
Settings::voices. (Voices on or off and their volume are player settings (#253)) - shuyi-ui: Ctx::voice_volume, Ctx::voice_gain, MAX_VOICE_VOLUME, DEFAULT_VOICE_VOLUME: gone; the voice volume is
Settings::voice_volume, 0 toMAX_VOLUME, andSettings::voice_factorits 0–1 factor. (Voices on or off and their volume are player settings (#253)) - shuyi-ui: settings::Settings: two new fields,
voicesandvoice_volume; code that builds one field by field adds them or ends with..Settings::default(). (Voices on or off and their volume are player settings (#253)) - shuyi-assets: TipLimits: a new field,
how_lines, the most lines of a tip'show(0 if the game's tips have none). (A tip keeps its instructions apart from its text (#302)) - shuyi-assets: Tip: a new field,
how, a tip's instructions apart from its text (Nonewhen a tip built in code has none). (A tip keeps its instructions apart from its text (#302))
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;
After:
let voice: &String = &cast.voice_for.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 = cast_from_source?;
// cast.ron: (voices: { "guide": "guide_a" })
After:
let cast: = ?;
// 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 ;
After:
let widths = of;
let rules = PackRules ;
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 ;
After:
let widths = of;
let rules = TipRules ;
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;
After:
let widths = of;
let longest = names.longest;
let cells = longest_width;
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 ;
After:
let atlas = FontAtlasDef ;
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;
After:
let w = text_width;
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;
After:
let shown = cut;
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:
run
After:
run
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;
After:
let lines = word_wrap;
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 = new;
After:
let text = new;
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?;
After:
let said = report?;
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
After:
FontAtlasDef
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
After:
LangInfo
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:
run
After:
run
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;
After:
let atlas = build_atlas;
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 = render_files?;
let p = tiles.pixel;
After:
let tiles: Image = render_files?;
let p = tiles.pixel.unwrap_or;
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:
render
After:
render
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?;
let bits = one_bit?;
After:
let png = encode_png?;
let bits = one_bit?;
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 = of_painted;
After:
let image = of_painted;
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
After:
match item
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": ,
"Cancel": ,
After:
"Confirm": ,
"Cancel": ,
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 ;
After:
let start = new.on_system;
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 ;
After:
let out = SceneOutput ;
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:
After:
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:
Boxnew
After:
Boxnew
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 ;
After:
let script = new;
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 = parse_step
script.check?;
After:
if let Some = parse_step
check?;
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!;
After:
input.steps = vec!;
The link's steps are a script's, with waits in seconds.
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!;
After:
let steps = vec!;
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?;
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:
new.play;
After:
new.play;
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 ;
After:
let = scene_output;
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
After:
EventsOutput
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:
After:
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;
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
After:
ExportCx
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:
Narrate
After:
Narrate
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
After:
Line
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
After:
VoiceClip
@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?;
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?;
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
After:
Settings
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
After:
TipLimits
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
After:
Tip
Repository and foundation
- The sound parity job runs only when a change can reach sound, on main too (#219)
- Check every shipped crate in the public API gate (#222)
- The API listing sees what the semver check sees, in every crate (#296)
- The sound gate's browser latency limit calibrated against more runs, so a noisy machine doesn't fail a pull request (#245)
- Mutation-test shuyi-sound's adapters, which a run on one package doesn't build (#246)
- The sound gate's Windows and decoder limits calibrated against more runs (#258)
- The sound gate's retry never throws away Windows timings it can't measure again (#251)
- The sound gate's native file read limit calibrated against more runs (#265)
- The sound gate's Windows recorder sometimes loses frames and fails the job (#261)
Core and simulation
- Ease curves in integers in shuyi_core::ease, tweens drawn from the time alone, and an ADR that takes easing out of the cinematic module (#272)
- Real time's first step: TickSim, records that replay, and TickDriver with its input latch (#257)
Assets and formats
- Sound PCM/source core and permissive WAV/Vorbis decoder adapters (#232)
- Hot reload of any asset kind: a file on disk changes and the running game sees it, through the registry, with errors shown instead of a crash (#221)
- Language tags in BCP 47 (pt-BR, zh-Hans), a fallback chain per key, matching the system's languages, and a source language the game declares (#276)
- Hot reload of the pictures a backend uploaded: a changed PNG redrawn in the running game (#225)
- Sounds a game makes at run time: samples handed to the audio queue as a cue, played on every backend (#285)
- A voice cast a voice tool can use: each speaker's voice, the tool's own settings, and a voice per clip variant (#291)
- Plan a language's voice clips: what to make, make again, record again or remove, and each voice's recording script (#303)
- The text checks measure with the font atlas's wide glyphs, and a reloaded atlas changes the frame's widths (#267)
- best_match: the game's language that fits the system's languages (#278)
- A source language names its wide set and its punctuation's width, as a pack's lang.ron does (#300)
- Screens know when a voice clip has ended (#301)
Frame, screens and skins
- Wide glyphs: a character two cells wide in the glyph buffer, drawn from a wide font in the atlas (#264)
- Line breaking for text without spaces (Japanese), and a report of translated text too long for its place (#271)
- Wide glyph sets per language: one character drawn by language, punctuation wide in one language only, and Chinese line breaking (#283)
- The debug view: gizmos drawn over the frame, channels turned on from the debug menu, and the grid kit's layout as the first channel (#284)
- The font atlas keeps the characters the text uses, and shuyi check fails on text it can't draw (#256)
- One RGBA picture type: the raster, glyph-tiles and font-atlas use shuyi_assets::image::Image (#295)
- Stop music with a fade chosen per request (#270)
- 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 (#274)
Input
- PadKind::Playdate follows the Playdate profile: A is East and B is West in its docs, its button names and its tests (#215)
- A key or button let go while the game is suspended stays held after the resume (#260)
Tools and AI support
- 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 (#234)
- 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 (#239)
- One operation plays a game's bots:
botsfolded intoplaytest, which answers each try's commands and record forreplay(#297) - The move-aside tests fail on Windows under load: find whether the program's path is ever empty, and make the tests hold (#229)
- One input script: the steps the Harness, the contract and the live link play, in shuyi-platform, with actions by name (#273)
- Establish sound parity baselines and automated correctness and speed gates before replacement (#206)
- The Harness starts a game from its real Start, records it, and plays a session record to any frame (#236)
- A misspelled action in a language file fails
shuyi check, rather than showing its braces to the player (#304) - Scene data derived from the game's state, and one value or its shape read by pointer through the contract (#306)
- The contract's answers list the sounds a game added at run time (#298)
Docs and community
- The tutorial: a box-pushing puzzle built step by step, each step a project CI builds and tests, each playable in the browser (#218)
- New games point their agent at the engine's docs: an {{engine.docs}} placeholder filled from the game's Engine (#217)
- The tutorial taught from nothing: for a reader new to programming, with the engine's words and Rust's explained, and before-and-after pictures of real edits (#226)
- The book taught from nothing: each chapter for a reader new to programming, with the engine's words and Rust's explained, and before-and-after pictures (#233)
- Code on the site that stands out: inline code with its own color and background, and code blocks with syntax colors of our own (#231)
Research
- Research: one description of a game's types and values for scripts, scenes, schemas and agents, decided in an ADR before 2101 and 2221 start (#282)
- Test gameplay clips that remain readable while disrupting video AI (#208)
- Test a small protected rules kernel against dynamic AI analysis (#235)
- Research capture controls, watermarks and AI-use signals for previews (#216)
Export targets
- Performance budgets: shuyi bench plays scenarios headlessly and gates instruction counts, peak memory and package size against every supported target (#277)
- The Playdate backend runs the game on a stack of its own taken from the heap, and reports how much of it was used (#214)
- In-house sound: the mixer in shuyi-sound: voices, volumes, fades, looping, streamed music and resampling, in integers (#237)
- Gated sound output on Windows through CPAL and the web through our own Web Audio bridge (#240)
- 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 (#268)
- Crash reports: a panic hook in shuyi-app writes a report through the game's storage, with the game's record of its play, and the game hears of it at the next start (#275)
- Files from the page: a web build lays the files its page holds over its own, as a bundle layer, and sends its problems back, through our own JS plugin (#293)
- The one-bit renderer's timing test fails under coverage instrumentation: a_fight_draws_fast runs only without it (#224)
- shuyi export builds a game's named features and ships the files it names beside the program (#212)
- Inspect each final export against a game's disclosure manifest (#211)
- The in-house web backend loses the start of a recording in a third of CI runs (#249)
- The Playdate backend's opt-in run report: when the loading card and the first frame were shown, the slowest reads, a minute's frame and stack figures, and the draw's phases (#266)
- A background policy the game sets: paused or muted while its window has no focus (#269)
- The grid template's crash record: its stage's record in a crash report, given through BasicGame (#279)
- A panic the game survives is reported, but not offered to the game as a crash (#280)
3D and HD-2D
- A game's icon with pictures drawn per size, used in place of the downscale (#210)
- A dialogue line carries a delivery direction, never shown, and a voice clip remembers it (#238)
Other tickets
- Voices on or off and their volume are player settings (#253)
- Hello, Grid and the grid template's stage offer the debug view's grid channel (#299)
- The options panel rises when its rows leave no room for the message (#294)
- A tip keeps its instructions apart from its text (#302)
Other changes
- 4 dependency updates
- 17 other pull requests
2026-10 — 2026-10-31
Breaking changes
- shuyi-cli: catalog::Catalog: a new field,
skills(the engine's skill pack); code that builds aCatalogfield by field addsskills: shuyi_cli::skills::Pack::default(), or reads one withCatalog::readorCatalog::embedded. (The engine's skill pack: Agent Skills for making games on the engine, tested in CI and shipped as a plugin (#101)) - shuyi-cli: template::Plan: a new field,
skills(the skills the plan copies); code that builds aPlanadds it. (The engine's skill pack: Agent Skills for making games on the engine, tested in CI and shipped as a plugin (#101)) - shuyi-cli: contract::new::NewOutput: a new field,
skills, in contract 18; code that builds one adds it, and a reader ofnew's answer may read it. (The engine's skill pack: Agent Skills for making games on the engine, tested in CI and shipped as a plugin (#101)) - shuyi-platform:
BasicGame::game: returnsResult<Game, ModuleError>; a game that calls it handles the error (?, ormap_err(|e| e.to_string())in aStart). (Modules: the catalog by concern, module metadata, the shuyi crate with a feature per module, and shuyi modules and shuyi add (#97)) - shuyi-cli:
contract::Op: gainsModulesandAdd; an exhaustivematchon it adds their arms. (Modules: the catalog by concern, module metadata, the shuyi crate with a feature per module, and shuyi modules and shuyi add (#97)) - shuyi-cli:
contract::new::ENGINE_CRATES: is a[&str; 7]withshuyifirst; code naming its type changes it. (Modules: the catalog by concern, module metadata, the shuyi crate with a feature per module, and shuyi modules and shuyi add (#97)) - shuyi-app: Window, Icon, VoiceFiles, AppConfig: build them with Window::new(..).with_icon(..), Icon::new, VoiceFiles::new and AppConfig::new, and assign voices and text_input (Public types that can grow without breaking a game: an ADR on #[non_exhaustive] and constructors, the exhaustive lints in shuyi-app, and Window, AppConfig, BoxPlace and MapScene moved onto it (#88))
- shuyi-ui: BoxPlace, MapScene, LogLine, NarrationAlign: build BoxPlace with BoxPlace::new(..).with_narration(..), MapScene with MapScene::new and field assignments, LogLine with LogLine::new; a match on NarrationAlign needs a
_arm (Public types that can grow without breaking a game: an ADR on #[non_exhaustive] and constructors, the exhaustive lints in shuyi-app, and Window, AppConfig, BoxPlace and MapScene moved onto it (#88)) - shuyi-assets: ContentError: build it with ContentError::new and its methods (Public types that can grow without breaking a game: an ADR on #[non_exhaustive] and constructors, the exhaustive lints in shuyi-app, and Window, AppConfig, BoxPlace and MapScene moved onto it (#88))
- shuyi-cli: LogEntry, contract::codes::Entry: read them field by field; they can't be built by literal (Public types that can grow without breaking a game: an ADR on #[non_exhaustive] and constructors, the exhaustive lints in shuyi-app, and Window, AppConfig, BoxPlace and MapScene moved onto it (#88))
- shuyi-cli: template::Fill: a new field
engine_ref, andFillis#[non_exhaustive]: build it withFill::default()and assign the fields. The game builds none. (A reusable playtest workflow: every stage played when what decides it changed, the summary in the job, a failure only on a floor; the grid template calls it (#106)) - shuyi-ui: Looks, MapColors, TerrainGlyphs, TextView, WaitView, CursorView, RowValue: build them with their
new(TextView and WaitView then assign the rest) and RowValue with RowValue::toggle, choice, volume or action; a match on RowValue needs a_arm and..in each variant (The engine's other config and views moved onto constructors: the types the templates and hello-grid build by literal in shuyi-ui, shuyi-platform, shuyi-assets and shuyi-core (#90)) - shuyi-platform: Look: build it with Look::new (The engine's other config and views moved onto constructors: the types the templates and hello-grid build by literal in shuyi-ui, shuyi-platform, shuyi-assets and shuyi-core (#90))
- shuyi-assets: audio::Files, SoundCue, MapFormat, TerrainTiles, DialogueTable, ImageTable, ImageInfo: build them with their
new(Files::new(..).with_music(..)) (The engine's other config and views moved onto constructors: the types the templates and hello-grid build by literal in shuyi-ui, shuyi-platform, shuyi-assets and shuyi-core (#90)) - shuyi-core: Tries, BalanceSettings, RolloutSettings, Condition, Change, PlayError, UnknownEntrant, Check, PlaytestError: build them with their
new, Condition with with_min and with_max, Check with Check::rate or Check::median (and with_floor, with_among), PlaytestError::Play with PlaytestError::play; a match on Check or PlaytestError needs a_arm and..in each variant (The engine's other config and views moved onto constructors: the types the templates and hello-grid build by literal in shuyi-ui, shuyi-platform, shuyi-assets and shuyi-core (#90)) - shuyi-cli: RunInput, CheckInput, ReplayInput, ImportInput, PlaytestInput, AutobalanceInput, BotsInput: build them with
default()and assign the fields (ReplayInput::new(record) and ImportInput::new(file) also work); NewInput with NewInput::new(template, dir); GameOption, GameLang, GameBake, OneBitSkin, Tunable, PlaytestPlan, AutobalancePlan, Play, Scene, Shot, Script, Project: build them with theirnew(GameOption::new(..).with_value(..)) and assign the rest; a match on Engine needs a_arm (The contract's inputs and the game tools' types moved onto constructors in shuyi-cli (#91)) - shuyi-assets:
pack::VERSION: 3, andPackInfoholds the sources' fingerprint; a game bakes its packs again (shuyi bake), and code building aPackInfoby literal addscontent. (Stamped records: the engine version, the game's stamp and a fingerprint of the content in records and saves, and shuyi replay naming a mismatch (#152)) - shuyi-assets:
bundle::Layer: a new public field,stands_for; code building aLayerby literal addsstands_for: None(a game usingLayer::dir,Layer::table,Layer::ownedand the builders changes nothing). (Stamped records: the engine version, the game's stamp and a fingerprint of the content in records and saves, and shuyi replay naming a mismatch (#152)) - shuyi-cli:
contract::replay::ReplayRun: a new field,made, in contract 27; a game'sreplaythat builds one by literal addsmade(its record's stamp, orNone); one built byreplay::turnschanges nothing. (Stamped records: the engine version, the game's stamp and a fingerprint of the content in records and saves, and shuyi replay naming a mismatch (#152)) - shuyi-assets:
bundle::Files: it is#[non_exhaustive]now and has a new variant,Owned; a game that matches onFilesadds a_ =>arm. (Bundle layers from outside the binary: files handed in at run time, and a folder on disk read by shuyi-app (#149)) - shuyi-assets: format::RegisterError: it is
#[non_exhaustive]and gainsReserved,SectionTwice,SectionWithoutDefaultandSectionAllowsUnknownFields, so amatchon it needs a_arm. (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 (#182)) - shuyi-ui: FrameOutput:
#[non_exhaustive]with two new fields,frame_capandframe_cap_serial: read it field by field (onlyGame::framebuilds one; the game builds none). (A frame cap: 60 by default, set by the game and the player, that works with vsync and VRR, and a request the platform confirms (#148)) - shuyi-cli: frame_png::Image::decode: call frame_png::decode_image(png) instead (The raster: frame-png's Painter moved into shuyi-ui behind an opt-in feature, as the picture every backend must match (#150))
- shuyi-ui: audio::AudioRequest::PlayMusic: gains
switch: Option<MusicSwitch>and is non-exhaustive: build it withAudioRequest::play_music(cue, None)and match it withPlayMusic { cue, .. }. (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 (#174)) - shuyi-ui: audio::MusicCommand::Start: gains
gain: f32, the gain the track starts at, and is non-exhaustive: a backend plays the track at that gain and matchesStart { cue, gain, .. }; build it withMusicCommand::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 (#174)) - shuyi-assets: audio::AudioManifest: gains
music_fade_in_msandmusic_overlapand is non-exhaustive: build it withAudioManifest::default()and assign its fields. Anaudio.ronreads as before. (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 (#174)) - shuyi-assets: pack::VERSION: 4 (the audio manifest's binary form has the two fields): a pack baked before is refused; bake it again with
shuyi bake. (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 (#174)) - shuyi-assets: formats.ron "audio manifest: audio-manifest": the manifest's state hash moved with its two new fields; a game's own golden row of its audio manifest moves the same way (bless it). (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 (#174))
- shuyi-ui: audio::AudioRequest: gains
StartSound,StopSoundandSoundVolumeand is non-exhaustive: amatchon it outsideshuyi-uineeds a wildcard arm. (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 (#179)) - shuyi-platform: audio::SoundRequest: gains
Start,StopandVolumeand is non-exhaustive: a backend'smatchon it needs a wildcard arm, and playsStart { id, cue, volume, .. }untilStop { id }. (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 (#179)) - shuyi-assets: pack::VERSION: 5 (a sound cue's binary form has
looped): a pack baked before is refused; bake it again withshuyi bake. (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 (#179)) - shuyi-assets: formats.ron "audio manifest: audio-manifest": the manifest's state hash moved with the sound cues'
looped; a game's own golden row of its audio manifest moves the same way (bless it). (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 (#179)) - shuyi-cli: RunOutput: it is
#[non_exhaustive]and has amodulesfield; a game reads it field by field and changes nothing (only shuyi-cli builds it) (Modules join a running game: an ADR, a Module trait, two hooks and state by type on Ctx, with debug menu entries, all listed in the contract (#93)) - shuyi-platform: Audio: gains the required methods
suspendandresume: a backend outside the engine implements both (pause, or mute where it can't, and bring back). (Sounds paused under a pause menu, and the audio held while the system suspends the game (#181)) - shuyi-cli:
contract::new::Templateandcontract::new::filesare gone;NewInput::new(template, dir)isNewInput::new(dir).template(name),NewInput::templateis anOption<String>(a short name), with new fieldsanswers,setandforce;NewOutputhas new fields; the contract's version is 17. The game calls none of them. (Templates as data: template.ron, parts that apply when an answer is picked, and a pure composer behind shuyi new (#98)) - templates: the placeholder
{{crate}}is{{crate_name}}, andtemplates/gridandtemplates/blankare the short names oftemplates/shuyi-default. (Templates as data: template.ron, parts that apply when an answer is picked, and a pure composer behind shuyi new (#98)) - shuyi-cli:
contract::doctor::Probe::read, a new required method ofProbe: nothing; no game implementsProbe(onlyshuyi-cli's tests do), so a game builds unchanged (Zero setup on Linux: shuyi doctor checks the C linker and the system libraries, one setup script for containers and cloud agents, a dev container, and CI from a bare Ubuntu (#138)) - shuyi-cli: contract::Op: a new variant
Attach, andOp::ALLhas 16 operations: a match on everyOpadds an arm; the contract's version is 20. The game matches none. (The live link: an ADR, a debug-only listener in shuyi-app, and shuyi attach and MCP reading a running windowed build (#117)) - none. (The dev containers register the MCP server after the setup script, so an agent in a container or a Codespace finds it (#145))
- shuyi-cli: contract::playtest::Played, PlaytestOutput: new fields
summary,wasandprevious, and now#[non_exhaustive]: build neither by literal (the engine does) (shuyi playtest keeps a history and says what changed:was …beside each entrant, and summary lines from the game's named measures (#105)) - shuyi-cli: contract::playtest::PlaytestArgs: new field
brief, and now#[non_exhaustive]: read it fromparse_args, don't build it by literal (shuyi playtest keeps a history and says what changed:was …beside each entrant, and summary lines from the game's named measures (#105)) - shuyi-cli: frame_png::Step: a new variant
Crankand#[non_exhaustive]; a game that matchesStepexhaustively adds a_arm (the game doesn't) (frame-png scripts wind the crank: a--crank DEGREESstep beside--keys,--padand--wait(#162)) - shuyi-cli: contract: output schemas'
opis a const of the op, not a reference toOp; version 26 (The CLI's seams: the stale-server limit says which side is newer, no run of spaces in a help line, one target lookup, one way to find cargo, and each answer's schema names its own op (#144)) - shuyi-cli: contract::Op: a new variant
Perf(the variants after it renumbered), andOp::SESSIONhas 8 tools: a match on everyOpadds an arm; the contract's version is 22. The game matches none. (Perf traces agents read: shuyi run --perf, an MCP perf tool read from a number, and a running debug build's trace file, as JSON lines (#119)) - shuyi-cli: mcp::StartInput:
#[non_exhaustive]with a new fieldlink, and the contract's version is 21: build it withStartInput::default()and assign its fields. The game builds none. (MCP's session attached to a running build over the live link: start with a link, then step, frame_png, scene, events, reset and stop (#118)) - shuyi-cli: mcp::StartOutput:
#[non_exhaustive]with a new fieldgame: read it field by field. The game builds none. (MCP's session attached to a running build over the live link: start with a link, then step, frame_png, scene, events, reset and stop (#118)) - shuyi-cli:
contract::Op: gainsExport, andOp::ALLis an[Op; 17]; an exhaustivematchonOpadds its arm. (shuyi export <target>: one command that checks a game and packages it for a target, starting with Windows, Linux, macOS and the web (#183)) - shuyi-assets: pack::BakedFile: it is
#[non_exhaustive]and has a new field,shown; read its fields, never build one with a struct literal. (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 (#190)) - shuyi-assets: Formats::register_standard: it registers the
onebitimport settings section (bitmap::dither::OneBitSettings) and the one-bit picture formats; a game that registered its ownonebitsection on a standard registry is refused (SectionTwice) and reads the engine's. (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 (#190)) - shuyi-cli: contract::bake::BakeInput and PackMade: both are
#[non_exhaustive],BakeInputhassheetandPackMadehasdithered,replacedandsheet; build an input withBakeInput::default()and assign its fields. The contract's version is 31. (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 (#190)) - shuyi-assets:
CrankDef::defaultand the golden hash of a keymap with nocrank: a notch is 45° unless the file says, was 30°. (shuyi-playdate: the Playdate backend (update callback, d-pad, A, B and crank, 1-bit display with changed rows, storage) and shuyi export playdate (#197)) - shuyi-cli:
ExportCx: two new public fields,appandfeatures: code that builds one with a literal adds them. (shuyi-playdate: the Playdate backend (update callback, d-pad, A, B and crank, 1-bit display with changed rows, storage) and shuyi export playdate (#197)) - shuyi-platform:
Look,Frame,Show:Lookholds a&dyn Images, so it is no longerSend,Syncor unwind safe, andFrameandShowno longer unwind safe: nothing here moved one across threads or acatch_unwind, and code that did draws on its own thread. (shuyi-playdate: the Playdate backend (update callback, d-pad, A, B and crank, 1-bit display with changed rows, storage) and shuyi export playdate (#197)) - shuyi-playdate:
Sdk: a type implementing it adds the sound calls and the (The audio player's rules shared by every backend, and audio on the Playdate: sounds baked to ADPCM, music streamed through the SDK (#201)) - shuyi-cli:
export::playdate::Dirs: a new field,build, the build's own (The audio player's rules shared by every backend, and audio on the Playdate: sounds baked to ADPCM, music streamed through the SDK (#201)) - shuyi-cli: web::run: takes a fourth argument,
game: &[notices::Work], the project's works forNOTICES.txt(pass&[]for none) (Web builds carry the notices they owe: a NOTICES.txt with the crates compiled in, the vendored scripts' licenses and the game's license texts, published with the Pages examples (#139)) - shuyi-cli: project::Config: gains
notices: Option<Notices>; a literal addsnotices: Noneor ends with..Config::default()(Web builds carry the notices they owe: a NOTICES.txt with the crates compiled in, the vendored scripts' licenses and the game's license texts, published with the Pages examples (#139)) - shuyi-cli: Config and TargetCheck are #[non_exhaustive], and TargetCheck gains App: a game builds
ConfigwithConfig::parse(orConfig::default()and assigns fields) and matchesTargetCheckwith a wildcard 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 (#186)) - shuyi-platform: BasicGame::new and BasicGame::with_own take an AppInfo in place of the name and the storage name: a game passes
AppInfo::new(name, id, version).with_storage(storage), keeping its old storage name so its players' saves keep loading (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 (#186)) - shuyi-ui: Ctx::keyboard: it is
ctx.platform.keyboard(Ctx::platform: PlatformFacts). (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 (#187)) - shuyi-platform: Setup::keyboard and Platform:
Setup::keyboardissetup.platform.keyboard, and aPlatformof the game's own implements the requiredfacts(&self) -> PlatformFacts(its target's, fromPlatformFacts::from_profile). (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 (#187)) - shuyi-assets: TargetProfile and target::Display: each has a new public field (
quit,windowed); code that builds one with a struct literal adds it. Files are unchanged: both fields have serde defaults. (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 (#187)) - shuyi-assets: pack::VERSION: 6 (a target profile's binary form has
quitandwindowed): a pack baked before is refused; bake it again withshuyi bake. (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 (#187)) - shuyi-assets: formats.ron "target profile: target-ron": the decoded profile has the two new fields, so its state hash moved; nothing for a game to change. (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 (#187))
- shuyi-cli: catalog::DEFAULT_CRATES: an array of 7 now, with
shuyi-alloc-count: code that names its type[&str; 6]changes the length; the contract's version is 23 (heapin each record and in the summary). The game names neither. (The heap counter in debug builds: a counting allocator in a default crate of its own, on in every debug build of a game and its tool program, and heap counts in each frame's record (#120)) - shuyi-app: a debug build now has a
#[global_allocator](shuyi_alloc_count::global): a game binary with an allocator of its own fails to link until its.cargo/config.tomlsetsrustflags = ["--cfg", "shuyi_no_heap_count"]. The game's only allocator is in its Playdate crate, which doesn't linkshuyi-app. (The heap counter in debug builds: a counting allocator in a default crate of its own, on in every debug build of a game and its tool program, and heap counts in each frame's record (#120)) - shuyi-app: Icon is now shuyi_platform::WindowIcon, re-exported under the same name: nothing changes in a game, since
shuyi_app::Icon,Icon::new,Window::iconandWindow::with_iconare the same (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) - shuyi-cli: app_info::files takes the project's root: a caller passes it, and gets a Result, since the window's icons are read from the icon (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)
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
After:
match error
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 = PlayMusic ;
if let PlayMusic = request
After:
let theme = play_music;
if let PlayMusic = 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:
Start => player.start,
After:
Start => player.start,
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 ;
After:
let mut audio = 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
After:
match request
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
After:
match request
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:
After:
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
After:
match op
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 = file;
After:
let BakedFile = 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 = standard;
formats.?;
After:
let 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 ;
After:
let mut input = default;
input.target = Some;
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 = default; // 30 degrees
After:
let crank = CrankDef ;
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
After:
ExportCx
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 _ = catch_unwind;
After:
paint;
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:
After:
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 ;
After:
let dirs = Dirs ;
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 ;
After:
let mut config = default;
config.command = Some;
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";
with_own
After:
const APP: AppInfo = new
.with_storage;
with_own
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 = Absent;
After:
ctx.platform.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
After:
start.before_load
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
After:
Display
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 = new.with_icon;
After:
const WINDOW: Window = new.with_icon;
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 = files;
After:
let files = files?;
Carved from the game
- The tool contract: shuyi new, check, run, scene, replay, bots, import, doctor, with JSON output (#26)
- An MCP server over the tool contract, with a session that keeps one headless game running (#56)
- The engine's skill pack: Agent Skills for making games on the engine, tested in CI and shipped as a plugin (#101)
Repository and foundation
- Set up the repository: plans, tickets, decisions, skills and licenses (no code)
- Gates for this repository before the code arrives: a ticket lint that knows game# ids, typos, and Markdown links, in CI (#8)
- Modules: the catalog by concern, module metadata, the shuyi crate with a feature per module, and shuyi modules and shuyi add (#97)
- check-keys and check-text on the engine's own code: a shuyi.ron at the root and both checks in CI (#146)
- Coverage, SonarCloud, and the Sonar debt queue as batched GitHub issues (#10)
- Fast compiles: a game's rebuild time measured, a CI gate that keeps it down, and a fast dev setup in the templates (#147)
- Every PR shows its public API change: a CI job with cargo-public-api diffs and cargo-semver-checks against the base, and a Breaking: line required when it breaks (#84)
- Dependabot for the crates and the pinned actions (the templates' too), a weekly security workflow with zizmor, CodeQL and Scorecard, and an ADR that makes a Dependabot PR its own ticket and keeps publishing tokens out of CI (#80)
- The pinned Rust in step: cargo xtask check-toolchain over the engine, the templates and rust-version, Dependabot's reminder of a new stable, and a bump-rust skill (#79)
- Public types that can grow without breaking a game: an ADR on #[non_exhaustive] and constructors, the exhaustive lints in shuyi-app, and Window, AppConfig, BoxPlace and MapScene moved onto it (#88)
- check-layers over cargo metadata, a float ban in shuyi-core, and a new game's rules crate kept pure (#62)
- The backlog and the docs kept true as tickets land: a ticket-lint warning on clauses about done tickets, a work-ticket step that settles them, and contract.md tested against the code (#52)
- The real window checked in CI: a capture mode in shuyi-app, a render card, and a job under Xvfb and llvmpipe that compares the window with the raster (#171)
- The web build checked in CI: headless Chromium loads it, fails on console errors and failed requests, and compares the canvas with the raster (#173)
- A reusable playtest workflow: every stage played when what decides it changed, the summary in the job, a failure only on a floor; the grid template calls it (#106)
- The Markdown a test reads counts as code in CI, and run-gates runs the templates job (#71)
- The ticket lint reads every way a ticket waits on another, the settle step reads the docs too, and a done ticket has no unchecked box (#141)
- The engine's other config and views moved onto constructors: the types the templates and hello-grid build by literal in shuyi-ui, shuyi-platform, shuyi-assets and shuyi-core (#90)
- The contract's inputs and the game tools' types moved onto constructors in shuyi-cli (#91)
- Main passes CI on Rust 1.99 again (clippy and the api job), and a Rust bump is checked on today's main before it merges (#89)
- No handoff on one side only: the backlog audited, the rule in write-ticket and work-ticket, and a ticket-lint error on a one-way clause (#99)
- Each PR is labelled with what it did to the public API (api: none, api: added, api: breaking), so a game pinned by git revision knows when to move its pin (#112)
- A push to main starts the watcher workflows of projects that pin the engine, with a token that can only run workflows (#113)
- A ticket is one PR of about 1,500 changed lines or fewer, up from 600 (#196)
- Make tickets and agent workflows provider neutral (#200)
- Correct model routing with explicit Claude and OpenAI pairs (#202)
Core and simulation
- Stamped records: the engine version, the game's stamp and a fingerprint of the content in records and saves, and shuyi replay naming a mismatch (#152)
- Timers in steps or turns: Countdown and Every in shuyi_core::timer, and the step rate moved into the core (#193)
- Determinism fixtures: golden tables of a record's hashes and of decoded assets, the same on x64, ARM64 and wasm32 under wasmtime in CI, and a moved value declared as a break (#169)
- A rollout bot over any TurnRules game: Determinize, and flat Monte Carlo on copies that never see the luck to come (#44)
- A playtest runner: K tries of each named bot over a stage, the game's measures, and pass bands as data (#45)
- An autobalance search: the game's knobs change a stage, never the bots, until every bot lands in its band (#53)
- A turn planner: a bot that plans its side's whole turn, several commands deep, on copies that never see the luck to come (#50)
- Playtest targets by tier: what a success is, a band per tier, floors and median comparisons in one file, turned into a stage's checks (#59)
- Weighted features: a bot's score as the game's named counts times weights from a file, and named personas (planner settings and weights) as data (#60)
- Threat areas in the tactics kit: what each piece can strike next turn, the danger at a tile from the game's forecast, and walking distance to the nearest goal (#102)
- A planner's candidate order from the game's kinds of command: each piece's best strike first, advances and holds by walking distance, and the end of the turn always within the cap (#104)
Assets and formats
- Formats are plugins: asset kinds, the Format trait, the registry, and today's loaders as built-in formats (#16)
- An optimized pack format for shipping: shuyi bake, one indexed compressed file, proven equal to its sources (#30)
- Bundle layers from outside the binary: files handed in at run time, and a folder on disk read by shuyi-app (#149)
- A folder loader on the bundle: parse every file of one extension in a folder into a map by name, with every error (#27)
- Word wrap in the assets crate, and a check that a line of text fits its box once its tokens are at their longest (#28)
- In-house sound: our own Ogg container reader: pages, CRC, packets and granule positions
- In-house sound: Vorbis, part 1: the bit reader, the three headers, and codebooks
- In-house sound: Vorbis, part 2: audio packets: modes, floors, residues and channel coupling
- In-house sound: Vorbis, part 3: the inverse MDCT, windows and overlap, a streaming decoder, and the ogg format
- A folder's assets file by file through the registry: each file of a kind in one folder, read or with its errors (#35)
- 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 (#182)
- Name tokens filled in dialogue: a Named hooks for a game whose only tokens are names (#155)
Frame, screens and skins
- The grid scene says where the map goes on past the view, and the grid skins let a game paint over the units and under the cursor (#41)
- The text box paints narration from the left like speech when the game asks, or centred as today (#40)
- A screen hands a result to the one below when it pops, and the one below hears that it is uncovered
- A list cursor: focus that wraps, skips disabled rows and stays valid after a removal, with a scroll window, under the menu
- Menu items carry a value of the game's type, and their labels are text keys translated when the menu is painted (#18)
- A question widget: a line of text and N answers with a focus and an answer for Cancel, its view and its glyph skin (#17)
- A player profiles screen (list, use, rename, delete, add) and a name entry widget, as engine screens like options (#20)
- Glyph drawing helpers: a box with a title in its border, a meter bar, and text centred in or cut to a width (#19)
- A frame time that is never negative or not a number, a clock a held action speeds up and a press skips, and the text box's Confirm step (#23)
- The kit's grid skin covers zoom, a held walk speed and a painter made each frame, and its glyph skin takes style options (#25)
- A frame cap: 60 by default, set by the game and the player, that works with vsync and VRR, and a request the platform confirms (#148)
- The raster: frame-png's Painter moved into shuyi-ui behind an opt-in feature, as the picture every backend must match (#150)
- 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 (#174)
- 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 (#179)
- Modules join a running game: an ADR, a Module trait, two hooks and state by type on Ctx, with debug menu entries, all listed in the contract (#93)
- A module's debug menu entries: declared in install, listed after the game's, and in run --json (#95)
- The render card: every frame item and paint in one frame, drawn by the window and by the raster in CI's render job (#172)
- Sounds paused under a pause menu, and the audio held while the system suspends the game (#181)
- The Key bindings screen's glyph skin scrolls rows that don't fit its panel (#180)
- The one-bit renderer's speed test holds on every runner: renders timed in pairs, and a bar a debug build's numbers support (#184)
Tools and AI support
- Templates as data: template.ron, parts that apply when an answer is picked, and a pure composer behind shuyi new (#98)
- Problem codes: a stable code and a help line on every error the contract gives, shuyi explain to read its page, and exit code 3 for fix the setup, then retry (#46)
- The short path: engine defaults for every GameData part, a prelude, run_game and BasicTools, and the blank template made minimal on them (#78)
- Zero setup on Linux: shuyi doctor checks the C linker and the system libraries, one setup script for containers and cloud agents, a dev container, and CI from a bare Ubuntu (#138)
- The live link: an ADR, a debug-only listener in shuyi-app, and shuyi attach and MCP reading a running windowed build (#117)
- Problem codes for the validators: unknown-name, duplicate and out-of-range in the keymap, the language packs and the maps (#48)
- The dev containers register the MCP server after the setup script, so an agent in a container or a Codespace finds it (#145)
- shuyi playtest plays the game's entrants itself: on every core, one stage or each, the entrants chosen, the game's defaults, and the time per try (#72)
- shuyi playtest keeps a history and says what changed:
was …beside each entrant, and summary lines from the game's named measures (#105) - shuyi lint: check-keys, check-text and the rules crate's fence in one answer, with file and line (#63)
- The MCP server is built before an agent connects, and says when the game's engine moved under it (#67)
- frame-png scripts wind the crank: a
--crank DEGREESstep beside--keys,--padand--wait(#162) - shuyi autobalance runs the search for a game's stage on every core, with a time limit, prints the changes and writes the stage back (#73)
- The CLI's seams: the stale-server limit says which side is newer, no run of spaces in a help line, one target lookup, one way to find cargo, and each answer's schema names its own op (#144)
- A running MCP server never blocks the next build: it moves its own program aside on Windows, and a cold build has time to finish (#76)
- The MCP server follows the session's checkout: it reads the client's roots and hands each project to that project's own server (#77)
- The MCP server reads the client's roots again when they change (#142)
- A game made from the blank template passes cargo fmt whatever its name (#92)
- Perf traces agents read: shuyi run --perf, an MCP perf tool read from a number, and a running debug build's trace file, as JSON lines (#119)
- Heap snapshots agents read: live bytes by tag at a frame, and a diff between two, in run --perf, the MCP perf tool and a running build's trace file (#134)
- Perf over the live link: a running debug build's frames read from a number, shuyi attach --perf-follow streaming them to a file, and the MCP perf tool on an attached session (#135)
- check-skills checks a project's own skills without asking for a worked example (#115)
- MCP's session attached to a running build over the live link: start with a link, then step, frame_png, scene, events, reset and stop (#118)
- Moving a running server's program aside never leaves its path empty, so the CLI's tests don't flake when they run in parallel (#121)
- Worked examples call the MCP session tools: an Mcp step in the script runner, and shuyi-perf's example takes a heap snapshot and a diff (#137)
- A game's start options checked once, on every path: MCP's start refuses an option the game doesn't offer (#157)
- check-keys finds every key and button the keymap names after press, hit, tap or hold (#156)
Game styles and examples
- The template's title quits only where the game can: Cancel and its help line by ctx.platform.can_quit(), and the rule in a new game's AGENTS.md (#189)
Docs and community
- The website: a Zola site on GitHub Pages with a home page, Learn, News, Examples, Community and Sponsor, ready for a custom domain (#140)
- The book: Learn on the website, with a quick start, a chapter per concept whose code compiles in CI, and the API docs (#163)
- News with an automatic changelog: cargo xtask changelog from the tickets done and the merges, a changelog page, and a post for each release (#161)
- Docs agents can read: llms.txt, llms-full.txt, a Markdown twin of every Learn page, and the contract's schemas on the site (#164)
- The docs map: one home for each kind of fact, by who reads it, a document skill that writes there, and a check that the map covers every Markdown file (#51)
- The site keeps up with the tickets: the home page's feature cards take their status from the tickets they name, and a public API change changes the book or has a Book line (#166)
Research
- Research: sound after quad-snd: our own decoders, mixer and output, or a library, and the tickets that follow (#203)
- Measure AI-assisted imitation, extraction and reconstruction separately (#207)
Export targets
- Target profiles as data: targets/*.ron, the TargetProfile asset kind, and shuyi check --target for screen size and text size (#29)
- Controls fit: every screen declares its actions, and each screen's required actions must bind to distinct inputs of the target (#34)
- The platform boundary: a shuyi-platform crate with the Platform trait and App::frame, and shuyi-app as its macroquad backend (#31)
- shuyi export <target>: one command that checks a game and packages it for a target, starting with Windows, Linux, macOS and the web (#183)
- no_std with alloc for shuyi-core, shuyi-ui and shuyi-platform, with a CI gate that builds them for thumbv7em-none-eabihf (#33)
- 1-bit output: palette roles map to 8×8 patterns, roles that tell things apart must stay distinct, and frame-png --target playdate shows the device's picture (#38)
- 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 (#190)
- shuyi-playdate: the Playdate backend (update callback, d-pad, A, B and crank, 1-bit display with changed rows, storage) and shuyi export playdate (#197)
- The audio player's rules shared by every backend, and audio on the Playdate: sounds baked to ADPCM, music streamed through the SDK (#201)
- The templates' screens declare their actions, and their tools offer the controls to check --target (#36)
- Web builds carry the notices they owe: a NOTICES.txt with the crates compiled in, the vendored scripts' licenses and the game's license texts, published with the Pages examples (#139)
- One log for the engine and the game: lines with a level, a category and a frame, App keeps the last 500, and the contract's run and scene answers carry them (#54)
- Saves that survive a power cut: flushed writes, the last good copy kept, and a power-cut suite every backend's storage runs (#153)
- 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 (#186)
- 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 (#187)
- One-bit sprites drawn fast: whole bytes for a picture shown pixel for pixel, and no division per pixel otherwise (#168)
- Frame times and spans as data: the platform's clock, spans a game marks, and App's record of each frame, in debug builds (#116)
- The heap counter in debug builds: a counting allocator in a default crate of its own, on in every debug build of a game and its tool program, and heap counts in each frame's record (#120)
- Memory tags: live bytes by the frame's phase, the game's spans and each asset kind's decode, counted by the heap counter in debug builds (#122)
- The web build's vendored scripts point at their notices: a header line in dist, and a rel="license" link on the page (#167)
- One clock for shuyi-app's frame: App::frame's time and the perf clock read the pacer's monotonic clock, not the system's time (#158)
- 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
- Web export builds from clean staging and refuses stale files (#205)
Other changes
- 4 dependency updates
- 50 other pull requests