Skip to content

Hand be-music skins the data from issues #239–#260 - #265

Merged
nulltask merged 29 commits into
develfrom
feature/skin-sdk-complete-frames
Oct 11, 2026
Merged

nulltask merged 29 commits into
develfrom
feature/skin-sdk-complete-frames

Conversation

@nulltask

@nulltask nulltask commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Summary

Settles the be-music skin SDK interface: every capability asked for in #239–#260 is now reachable from the frames, even where no built-in skin uses it yet. The SDK is not a public API yet, so the skin API stays at revision 1.

Compatibility

  • Everything new is additive and optional, so existing skins keep running unchanged.
  • Anything that changes what the player draws is opt-in, following the existing drawsMines pattern: drawsLaneCover, drawsMeasureLines, drawsBga.
  • The built-in skins (Synesthesia, Lattice, Phantom, Plain) render as before.

Gameplay

Select

Result

All screens

New screen

Shared engine (@be-music/player)

  • New UI commands long-note-start / long-note-end.
  • New PlayerOptions.onRulesetResolved callback carrying the resolved judge windows.
  • The TUI worker now ignores commands it doesn't recognise. It used to clear the POOR BGA on them.

SDK helpers

  • binJudgeTimings and resolveBeMusicSkinSettings / validateBeMusicSkinSettings.

Docs (docs/be-music-skin.md and .ja.md) and changesets are updated.

Known limitations

  • judgeWindows reflects the windows at chart start; a mid-chart #EXRANK change is not reflected.
  • visibleMs / greenNumber use the 1P cover and the current BPM, ignoring #SCROLL / #SPEED. lift is always 0 because the player has no lift option.
  • Song records are written for default-family results only; beatoraja-theme plays are not recorded yet. The CLEAR select sort does not use the records yet.
  • The decide screen and skin-drawn BGA have no built-in skin using them. Unit tests cover them, but they have not been exercised in the browser.

Closes #239, closes #240, closes #241, closes #242, closes #243, closes #244, closes #245, closes #246, closes #247, closes #248, closes #249, closes #250, closes #251, closes #252, closes #253, closes #254, closes #255, closes #256, closes #257, closes #258, closes #259, closes #260

Test plan

  • pnpm run check: format, lint and types.
  • pnpm test: 2413 tests.
  • Synesthesia and Phantom select, play and result screens render as before, with no console errors.
  • The skin-settings path was checked in the demo with a temporary skin that declares settings.

🤖 Generated with Claude Code

Add every field, type, and opt-in flag the skin SDK v2 needs (#239-#260)
in one go so skins can be written against a stable interface while the
host fills the values in over the following commits.

- Raise BE_MUSIC_SKIN_API_VERSION to 2 and keep accepting revision 1.
- Move the play options shape into the SDK as BeMusicPlayOptions;
  PixiPlayOptions and its unions stay as aliases.
- Add resolveBeMusicSkinSettings / validateBeMusicSkinSettings (hooked
  into validateBeMusicSkin) and binJudgeTimings, with tests and bench
  cases.
- Compile the host through: bindings read the new draws* flags, frames
  carry empty measure lines, no BGA layers, default settings, and empty
  song images / records; select wires setPlayOption, cyclePlayOption,
  and toggleReadme to the existing play option and readme code.
- Document the new fields, opt-ins, decide screen, skin settings, song
  images, and the timing sign convention in English and Japanese.
Log every judgement the engine reports with a timing offset and pass it
to skins: the latest offset as runtime.lastJudgeDeltaMs, the last 64 as
runtime.judgeTimings for timing meters, and the whole play as the result
data's judgeTimings for histograms (#240, #253).

The recent window lives in a ring buffer whose oldest-first view is only
rebuilt when a judgement arrives, so reading it every frame allocates
nothing. Judgements without a timing (autoplay hits, missed notes) are
skipped, as the SDK documents.
…ents

Pass be-music skins the rest of the per-play scalars the v2 runtime
declares:

- song: the chart being played (#239).
- minBpm / maxBpm / mainBpm: worked out once per chart from its tempo
  points, mainBpm being the BPM held longest (#245).
- songLengthMs / songElapsedMs: the song length and the audio-clock
  playhead, clamped to the length so the remaining time never goes
  negative through the audio tail (#246).
- failedAtMs / allNotesDoneAtMs / outroAtMs: latched when the gauge
  fails mid-play, the last note is judged, and the chart ends (#247).
- gaugeRiseAtMs / gaugeMaxAtMs: latched when the gauge first rises above
  its start and first tops out (#260).
- scoreHistory / gaugeHistory: the live sample arrays, by reference (#252).
Stamp each lane's last press and release on the play clock, autoplay
hits included, and hand them to be-music skins as the lane frame's
pressedAtMs / releasedAtMs (#255).

Mark notes and long notes the engine plays itself with autoplay: every
note under autoplay, and the scratch lanes' notes under auto-scratch,
mirroring the engine's single auto-scratch flag (a replay's recorded
flag wins) (#260).
Fill the result data's lamp, gaugeType, judgeRuleset, and fast / slow
counts (#249). The lamp comes from a new resolveClearLamp helper: FAILED
when the gauge failed, MAX / PERFECT / FULL_COMBO when every note was
judged PERFECT / GREAT / GOOD or better, and otherwise the lamp of the
gauge the play cleared on (DEATH and HAZARD count as EX_HARD).
… skins

The gameplay runtime now carries laneCovers (SUDDEN+ / HIDDEN+ per play side, as the scene's shutter draws them), visibleMs, and greenNumber, worked out the way beatoraja's duration readouts are but over the core view's pixel scroll. A skin that sets drawsLaneCover draws the covers itself and the scene stops drawing its own.
A skin that sets drawsMeasureLines now receives frame.measureLines, one line per run of adjacent lanes so double play's gap stays clear, and the scene skips its own lines. Other skins keep the scene's lines and an empty array.
Bombs fired by a PERFECT or GREAT carry that judgement as judge; the bomb an autoplayed long-note head fires has none.
The select frame's pointer now follows the mouse in the stage's design pixels (undefined off the stage), so hit() reports hover for the rect under it.
…untimes

Push long-note-start / long-note-end UI commands wherever a hold begins and ends (autoplay, auto-scratch, manual play), carrying the mode the ruleset plays the note in and whether it completed or was dropped. Add PlayerOptions.onRulesetResolved so a host can read the judge windows the engine actually uses, including a replay's recorded settings. The TUI worker now only clears the POOR BGA on clear-poor-bga instead of on any unrecognised command.
…c skins

Track the engine's long-note start / end commands per lane to label each drawn long note with its mode and state (pending, held, dropped, done) and to fill the lanes' holding / holdStartedAtMs. Convert the ruleset windows the engine reports at launch into the runtime's judgeWindows; a mid-chart #EXRANK change is not reflected.
A skin's setPlayOption accepted any numeric value under an unknown key, growing the play options with junk, and cyclePlayOption indexed past the choices for a step other than 1 or -1. Both now go through pure helpers that reject unknown keys and non-finite numbers and clamp the step to one choice either way.
The core select scene takes a resolveSongRecord option and the select binding answers frame.songRecord(song) with it. Without the option every song still reads as never played.
Each played (not autoplayed or replayed) LR2/default-family result folds into the chart's best record: best lamp, EX score and max combo, fewest BAD + POOR, and a play count. Records are keyed by the chart-file SHA-256 (falling back to the chart path), persisted in localStorage under be-music-demo.song-records, and served to the select skin through resolveSongRecord.
… and back images

Select, gameplay and result frames now resolve songImage from a per-scene ImageBitmap cache that loads lazily on the first miss, remembers missing images, closes the least recently used bitmap past 32 and everything on dispose. Select bumps its frame revision when an image arrives.
…e it

A skin that sets drawsBga now receives frame.bga with the active base, layer and POOR images (decoded bitmaps, the playing video element, or cropped copies of #BGA sub-regions) and poorActive, and the player stops compositing its own BGA for it. The docs spell out that bmson and video layers arrive unkeyed and that #ARGB tints are left to the skin.
DefaultPixiDecideView draws the skin's decide screen through a new BeMusicDecideBinding for its durationMs (default 2000), with the chart, the play options, and song images. Enter, Space or a click skip ahead and Escape cancels back to select, as on the LR2 decide scene.
…e play

When the active be-music skin defines decide (and the play doesn't run on a beatoraja theme), the demo mounts DefaultPixiDecideView between select and gameplay, preloading the play behind it as the LR2 decide path does. Skins without one go straight to the play as before.
The gameplay, select, result, and decide scenes take the player's stored setting values through a beMusicSkinSettings option and setBeMusicSkinSettings, check them against the skin's schema with resolveBeMusicSkinSettings, and hand them to the skin as frame.settings from the next frame. Select also bumps its revision so skins that cache per revision redraw. Without stored values skins keep drawing with their defaults. resolveBeMusicSkinSettings is re-exported from the skin subpath for hosts building a settings UI.
A Skin settings folder is built from the active skin's settings schema (choice as a dropdown, number as a slider with its unit in the name, toggle as a checkbox), rebuilt when the skin changes, and hidden for skins that declare none. Values are stored per skin id in localStorage and reach the scenes on screen at once; a reset button goes back to the defaults.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
be-music-player-demo 05c5c7a Commit Preview URL

Branch Preview URL
Oct 11 2026, 03:41 PM

@nulltask nulltask changed the title Freeze the skin SDK at v2 and hand skins the data from issues #239–#260 Hand be-music skins the data from issues #239–#260 Oct 11, 2026
…ripping

The benchmarks import the sources through Node's strip-only TypeScript support, which rejects parameter properties, so the head benchmark crashed on JudgeTimingLog's constructor.
The skin SDK is not a public API yet, so the additions stay in revision 1 instead of starting a revision 2. The changesets are renamed and reworded to match.
@nulltask
nulltask force-pushed the feature/skin-sdk-complete-frames branch from dfef35c to 05c5c7a Compare October 11, 2026 15:40
@github-actions

Copy link
Copy Markdown

Exports Benchmark

  • Base SHA: b3c1fc672395
  • Head SHA: 05c5c7a8c4c4
  • Comparable cases: 92
  • Regression threshold: 8.00%
  • Overall verdict uses the median change across cases.
  • Per-case lists compare median ops/s, not mean.
  • Base runs: median of 3
  • Head runs: median of 3

Summary

Metric Value
Overall unchanged
Median change +0.23%
Mean change +0.60%
Cases improved (>= threshold) 1
Cases regressed (<= -threshold) 1
Cases unchanged 90
Cases excluded (sub-timer-resolution) 211
Head benchmarked cases 307
Head skipped cases 22

Top Regressions

API Base median ops/s Head median ops/s Change
audio-renderer.writeAudioFile 4123.25 3373.99 -18.17%

Top Improvements

API Base median ops/s Head median ops/s Change
player-web.buildAudioBus 304136.25 478927.20 +57.47%

Excluded (sub-timer-resolution)

Per-call latency at or below 0.001ms on at least one side — the reported time is measurement noise, not the case's real cost, so no percent change is shown.

API Base median ops/s Head median ops/s
audio-renderer.createTimingBuildContext 1953124.99 1949317.74
chart.compareEvents 10416666.69 10416666.69
chart.createBeatResolver 20833332.60 20833332.60
chart.eventToBeat 13888889.10 13888889.10
chart.exWavVolumeCentibelsToLinearGain 15625000.04 15625000.04
chart.getMeasureBeats 20833332.60 20833332.60
chart.isBmsBgmVolumeChangeChannel 17857142.61 17857142.61
chart.isBmsDynamicVolumeChangeChannel 17857142.61 17857142.61
chart.isBmsKeyVolumeChangeChannel 17857142.61 15625000.04
chart.isBmsLongNoteChannel 20833332.60 20833332.60
chart.isLandmineChannel 20833332.60 20833332.60
chart.isPlayableChannel 20833332.60 20833332.60

Newly Skipped

  • player-web.DefaultPixiDecideView: Interactive function is skipped by default. Pass --include-interactive to benchmark it.

@nulltask
nulltask merged commit 8433ad3 into devel Oct 11, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment