From 7106d3f5581e5a69f1635c05d4f923ce211ef2dc Mon Sep 17 00:00:00 2001 From: "dtinth-claw[bot]" <290121326+dtinth-claw[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:59:57 +0700 Subject: [PATCH 1/3] Add base62 keysound-ID support to the bms package BMS charts reference keysounds/events with 2-character IDs. Traditionally these are base-36 (0-9A-Z) and case-insensitive, and the bms package relied on this by folding IDs to lowercase everywhere. Charts that declare `#BASE 62` use case-sensitive IDs (0-9a-zA-Z), where `Aa`, `AA`, and `aA` are distinct. This routes all four case-collapse points through a single, base-aware `normalizeIdSuffix` helper, so behavior is unchanged in base-36 but IDs become case-sensitive end-to-end in base-62: - `bms/headers`: separates the (case-insensitive) command prefix from the ID suffix for #WAVxx/#BMPxx/#BPMxx/#STOPxx, and preserves the suffix case in base-62. Stores the chart base (from the `#BASE` header). - `compiler`: detects `#BASE` up-front so ID-indexed headers normalize correctly regardless of where `#BASE` appears. - `keysounds`: stores/looks up keysound IDs using the chart base. - `notes`: matches #LNOBJ case-sensitively in base-62. - `bms/chart`: exposes `.base`. - `#BPMxx`/`#STOPxx` timing lookups inherit the fix via headers, no change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014Wv1XChhvMw5LwiVDv3NWh --- packages/bms/spec/base62_spec.js | 36 +++++++++++++++++++ packages/bms/spec/fixtures/base62.bms | 10 ++++++ packages/bms/spec/keysounds_spec.js | 52 ++++++++++++++++++++++++++- packages/bms/spec/notes_spec.js | 26 ++++++++++++++ packages/bms/spec/timing_spec.js | 33 +++++++++++++++++ packages/bms/src/bms/chart.ts | 9 +++++ packages/bms/src/bms/headers.ts | 45 +++++++++++++++++++++-- packages/bms/src/compiler/index.ts | 7 ++++ packages/bms/src/keysounds/index.ts | 17 ++++++--- packages/bms/src/notes/index.ts | 10 ++++-- packages/bms/src/util/id.ts | 34 ++++++++++++++++++ 11 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 packages/bms/spec/base62_spec.js create mode 100644 packages/bms/spec/fixtures/base62.bms create mode 100644 packages/bms/src/util/id.ts diff --git a/packages/bms/spec/base62_spec.js b/packages/bms/spec/base62_spec.js new file mode 100644 index 000000000..763fe02d1 --- /dev/null +++ b/packages/bms/spec/base62_spec.js @@ -0,0 +1,36 @@ +const { Reader, Compiler, Keysounds, Notes } = require('../lib') +const fs = require('fs') +const path = require('path') +const { expect } = require('chai') + +function fixture(name) { + return fs.readFileSync(path.resolve(__dirname, 'fixtures/' + name)) +} + +describe('base62 keysound IDs (#BASE 62)', function () { + function loadFixture() { + const source = Reader.read(fixture('base62.bms')) + return Compiler.compile(source).chart + } + + it('should expose the chart base', function () { + expect(loadFixture().base).to.equal(62) + expect(Compiler.compile('#TITLE hi').chart.base).to.equal(36) + }) + + it('should keep differently-cased WAV ids distinct end-to-end', function () { + const keysounds = Keysounds.fromBMSChart(loadFixture()) + expect(keysounds.get('Aa')).to.equal('lower.wav') + expect(keysounds.get('AA')).to.equal('upper.wav') + expect(keysounds.get('aA')).to.equal('mixed.wav') + }) + + it('should preserve object value case so notes reference distinct sounds', function () { + const chart = loadFixture() + const notes = Notes.fromBMSChart(chart) + const keysounds = Keysounds.fromBMSChart(chart) + // Channel data `AaAAaA` => three notes referencing Aa, AA, aA. + const filenames = notes.all().map((note) => keysounds.get(note.keysound)) + expect(filenames).to.deep.equal(['lower.wav', 'upper.wav', 'mixed.wav']) + }) +}) diff --git a/packages/bms/spec/fixtures/base62.bms b/packages/bms/spec/fixtures/base62.bms new file mode 100644 index 000000000..12694083c --- /dev/null +++ b/packages/bms/spec/fixtures/base62.bms @@ -0,0 +1,10 @@ +#TITLE base62 sample +#ARTIST bemuse +#BASE 62 +#BPM 120 + +#WAVAa lower.wav +#WAVAA upper.wav +#WAVaA mixed.wav + +#00111:AaAAaA diff --git a/packages/bms/spec/keysounds_spec.js b/packages/bms/spec/keysounds_spec.js index f02cf8ff5..b760ccd79 100644 --- a/packages/bms/spec/keysounds_spec.js +++ b/packages/bms/spec/keysounds_spec.js @@ -1,7 +1,57 @@ -const { Keysounds } = require('../lib') +const { Keysounds, Compiler } = require('../lib') const { expect } = require('chai') describe('Keysounds', function () { + describe('.fromBMSChart', function () { + it('should map keysound IDs to filenames', function () { + const chart = Compiler.compile('#WAVAA cat.wav\n#WAVBB dog.wav').chart + const keysounds = Keysounds.fromBMSChart(chart) + expect(keysounds.get('aa')).to.equal('cat.wav') + expect(keysounds.get('bb')).to.equal('dog.wav') + }) + + it('should fold case in base-36 charts (default)', function () { + const chart = Compiler.compile('#WAVAA cat.wav').chart + const keysounds = Keysounds.fromBMSChart(chart) + expect(keysounds.get('aa')).to.equal('cat.wav') + expect(keysounds.get('AA')).to.equal('cat.wav') + expect(keysounds.get('Aa')).to.equal('cat.wav') + }) + + it('should let a later definition override an earlier one (base 36)', function () { + const chart = Compiler.compile('#WAVAA cat.wav\n#WAVaa dog.wav').chart + const keysounds = Keysounds.fromBMSChart(chart) + expect(keysounds.get('aa')).to.equal('dog.wav') + }) + + describe('with #BASE 62', function () { + it('should treat differently-cased IDs as distinct keysounds', function () { + const chart = Compiler.compile( + '#BASE 62\n#WAVAa lower.wav\n#WAVAA upper.wav\n#WAVaA mixed.wav' + ).chart + const keysounds = Keysounds.fromBMSChart(chart) + expect(keysounds.get('Aa')).to.equal('lower.wav') + expect(keysounds.get('AA')).to.equal('upper.wav') + expect(keysounds.get('aA')).to.equal('mixed.wav') + }) + + it('should be case-sensitive on lookup', function () { + const chart = Compiler.compile('#BASE 62\n#WAVAa cat.wav').chart + const keysounds = Keysounds.fromBMSChart(chart) + expect(keysounds.get('Aa')).to.equal('cat.wav') + expect(keysounds.get('AA')).to.equal(undefined) + expect(keysounds.get('aa')).to.equal(undefined) + }) + + it('should work regardless of where #BASE appears', function () { + const chart = Compiler.compile('#WAVAa cat.wav\n#BASE 62').chart + const keysounds = Keysounds.fromBMSChart(chart) + expect(keysounds.get('Aa')).to.equal('cat.wav') + expect(keysounds.get('AA')).to.equal(undefined) + }) + }) + }) + describe('#files', function () { it('should get list of all files', function () { expect( diff --git a/packages/bms/spec/notes_spec.js b/packages/bms/spec/notes_spec.js index b686130c5..b0d5134fb 100644 --- a/packages/bms/spec/notes_spec.js +++ b/packages/bms/spec/notes_spec.js @@ -33,6 +33,32 @@ describe('Notes', function () { expect(notes.all()).to.have.length(3) }) + describe('LNOBJ', function () { + it('should fold case in base-36 charts (default)', function () { + // The trailing `aa` matches `#LNOBJ AA` case-insensitively, so it + // terminates the `0B` note instead of creating a new one. + const chart = Compiler.compile('#LNOBJ AA\n#00111:0Baa').chart + const notes = Notes.fromBMSChart(chart) + expect(notes.all()).to.have.length(1) + expect(notes.all()[0].endBeat).to.not.equal(undefined) + }) + + describe('with #BASE 62', function () { + it('should terminate a note only on an exact-case match', function () { + const chart = Compiler.compile('#BASE 62\n#LNOBJ AA\n#00111:0BAA').chart + const notes = Notes.fromBMSChart(chart) + expect(notes.all()).to.have.length(1) + expect(notes.all()[0].endBeat).to.not.equal(undefined) + }) + + it('should not terminate a note on a differently-cased ID', function () { + const chart = Compiler.compile('#BASE 62\n#LNOBJ AA\n#00111:0Baa').chart + const notes = Notes.fromBMSChart(chart) + expect(notes.all()).to.have.length(2) + }) + }) + }) + describe('custom mapping', function () { it('allows custom mapping', function () { const chart = Compiler.compile('#00112:01').chart diff --git a/packages/bms/spec/timing_spec.js b/packages/bms/spec/timing_spec.js index 636ee14b0..b0dd265c0 100644 --- a/packages/bms/spec/timing_spec.js +++ b/packages/bms/spec/timing_spec.js @@ -52,5 +52,38 @@ describe('Timing', function () { const t = Timing.fromBMSChart(chart) expect(t.bpmAtBeat(5)).to.equal(123.45) }) + + it('should fold #EXBPM id case in base-36 charts (default)', function () { + const chart = Compiler.compile('#BPMAA 123.45\n#00108:aa').chart + const t = Timing.fromBMSChart(chart) + expect(t.bpmAtBeat(5)).to.equal(123.45) + }) + + describe('with #BASE 62', function () { + it('should resolve #EXBPM ids case-sensitively', function () { + const chart = Compiler.compile( + '#BASE 62\n#BPMAa 200\n#BPMAA 100\n#00108:Aa' + ).chart + const t = Timing.fromBMSChart(chart) + expect(t.bpmAtBeat(5)).to.equal(200) + }) + + it('should not resolve an #EXBPM id of the wrong case', function () { + const chart = Compiler.compile('#BASE 62\n#BPMAa 200\n#00108:AA').chart + const t = Timing.fromBMSChart(chart) + // `AA` is undefined in base-62, so no BPM change happens. + expect(t.bpmAtBeat(5)).to.equal(60) + }) + + it('should resolve #STOP ids case-sensitively', function () { + const chart = Compiler.compile('#BASE 62\n#STOPAa 48\n#00109:Aa').chart + const t = Timing.fromBMSChart(chart) + // The stop object lands on measure 1 (beat 4). #STOPAa is 48 ticks = + // one beat, and the default BPM is 60, so it delays everything past + // beat 4 by one second: beat 5 shifts from 5s to 6s. + expect(t.getEventBeats()).to.deep.equal([4]) + expect(t.beatToSeconds(5)).to.be.closeTo(6, 1e-2) + }) + }) }) }) diff --git a/packages/bms/src/bms/chart.ts b/packages/bms/src/bms/chart.ts index 719e3ad84..e511571b3 100644 --- a/packages/bms/src/bms/chart.ts +++ b/packages/bms/src/bms/chart.ts @@ -33,6 +33,15 @@ export class BMSChart { this.timeSignatures = new TimeSignatures() } + /** + * The numeric base used for keysound/event IDs in this chart, as declared + * by the `#BASE` header. Defaults to `36` (case-insensitive IDs); a value of + * `62` makes IDs case-sensitive. + */ + get base(): number { + return this.headers.base + } + /** * Public: Converts measure number and fraction into beat. * A single beat is equivalent to a quarter note in common time signature. diff --git a/packages/bms/src/bms/headers.ts b/packages/bms/src/bms/headers.ts index cf53e1560..5a5499ad1 100644 --- a/packages/bms/src/bms/headers.ts +++ b/packages/bms/src/bms/headers.ts @@ -1,3 +1,5 @@ +import { ID_INDEXED_COMMAND, normalizeIdSuffix } from '../util/id' + /** * A BMSHeader holds the header information in a BMS file, such as * `#TITLE`, `#ARTIST`, or `#BPM`. @@ -18,12 +20,49 @@ export class BMSHeaders { private _data: { [field: string]: string } private _dataAll: { [field: string]: string[] } + private _base = 36 constructor() { this._data = {} this._dataAll = {} } + /** + * The numeric base used for keysound/event IDs in this chart. + * Defaults to `36` (case-insensitive IDs); `62` enables case-sensitive IDs. + */ + get base(): number { + return this._base + } + + /** + * Sets the numeric base for keysound/event IDs. + * + * This must be set (by the {Compiler}, from the `#BASE` header) before any + * ID-indexed headers are stored, so that their keys are normalized + * consistently with later lookups. + * @param base the numeric base (`36` or `62`) + */ + setBase(base: number) { + this._base = base + } + + /** + * Normalizes a header field name into its storage key. + * + * The command prefix is always folded to lowercase (case-insensitive), + * while for ID-indexed commands (`#WAVxx`, `#BMPxx`, `#BPMxx`, `#STOPxx`) + * the 2-character ID suffix is normalized according to the chart’s base: + * lowercased in base-36, but case-preserved in base-62. + */ + private _normalizeName(name: string): string { + const match = name.match(ID_INDEXED_COMMAND) + if (match) { + return match[1].toLowerCase() + normalizeIdSuffix(match[2], this._base) + } + return name.toLowerCase() + } + /** * Iterates through each header field using a callback function. * @param callback will be called for each header field @@ -40,7 +79,7 @@ export class BMSHeaders { * @return the field’s latest value */ get(name: string): string | undefined { - return this._data[name.toLowerCase()] + return this._data[this._normalizeName(name)] } /** @@ -49,7 +88,7 @@ export class BMSHeaders { * @param name field’s name */ getAll(name: string): string[] | undefined { - return this._dataAll[name.toLowerCase()] + return this._dataAll[this._normalizeName(name)] } /** @@ -58,7 +97,7 @@ export class BMSHeaders { * @param value field’s value */ set(name: string, value: string) { - const key = name.toLowerCase() + const key = this._normalizeName(name) this._data[key] = value ;(this._dataAll[key] || (this._dataAll[key] = [])).push(value) } diff --git a/packages/bms/src/compiler/index.ts b/packages/bms/src/compiler/index.ts index f8a872563..f784de84d 100644 --- a/packages/bms/src/compiler/index.ts +++ b/packages/bms/src/compiler/index.ts @@ -35,6 +35,13 @@ export function compile(text: string, options?: Partial) { const chart = new BMSChart() + // The `#BASE` header controls whether keysound/event IDs are case-sensitive. + // It is detected up-front (before the main pass) so that ID-indexed headers + // are stored with the correct case normalization regardless of where `#BASE` + // appears in the file. + const baseMatch = text.match(/^\s*#BASE\s+(\d+)/im) + if (baseMatch) chart.headers.setBase(+baseMatch[1]) + const rng = options.rng || function (max) { diff --git a/packages/bms/src/keysounds/index.ts b/packages/bms/src/keysounds/index.ts index 57c2cf848..02a81e8d3 100644 --- a/packages/bms/src/keysounds/index.ts +++ b/packages/bms/src/keysounds/index.ts @@ -3,6 +3,7 @@ import { uniq, values } from '../util/lodash' import { BMSChart } from '../bms/chart' +import { normalizeIdSuffix } from '../util/id' /** * A simple mapping between keysounds ID and the file name. @@ -29,8 +30,15 @@ import { BMSChart } from '../bms/chart' */ export class Keysounds { _map: { [id: string]: string } - constructor(map: { [id: string]: string }) { + _base: number + /** + * @param map a mapping from (normalized) keysound ID to filename + * @param base the chart’s numeric base (36 by default, 62 for case-sensitive + * IDs). This governs how IDs passed to {get} are normalized. + */ + constructor(map: { [id: string]: string }, base = 36) { this._map = map + this._base = base } /** @@ -39,7 +47,7 @@ export class Keysounds { * @returns the sound filename */ get(id: string): string | undefined { - return this._map[id.toLowerCase()] + return this._map[normalizeIdSuffix(id, this._base)] } /** @@ -66,12 +74,13 @@ export class Keysounds { */ static fromBMSChart(chart: BMSChart) { void BMSChart + const base = chart.base const map: { [id: string]: string } = {} chart.headers.each(function (name, value) { const match = name.match(/^wav(\S\S)$/i) if (!match) return - map[match[1].toLowerCase()] = value + map[normalizeIdSuffix(match[1], base)] = value }) - return new Keysounds(map) + return new Keysounds(map, base) } } diff --git a/packages/bms/src/notes/index.ts b/packages/bms/src/notes/index.ts index 5dde4c4b5..516a9e209 100644 --- a/packages/bms/src/notes/index.ts +++ b/packages/bms/src/notes/index.ts @@ -3,6 +3,7 @@ import invariant from 'invariant' import * as ChannelMapping from './channels' import { BMSChart } from '../bms/chart' import { BMSObject } from '../bms/objects' +import { normalizeIdSuffix } from '../util/id' export { BMSNote } @@ -79,6 +80,7 @@ class BMSNoteBuilder { _activeLN: { [channel: string]: BMSNote } _lastNote: { [channel: string]: BMSNote } _lnObj: string + _base: number _channelMapping: { [channel: string]: string } _objects: BMSObject[] constructor(chart: BMSChart, options: { mapping: BMSChannelNoteMapping }) { @@ -92,7 +94,11 @@ class BMSNoteBuilder { this._notes = [] this._activeLN = {} this._lastNote = {} - this._lnObj = (this._chart.headers.get('lnobj') || '').toLowerCase() + this._base = this._chart.base + this._lnObj = normalizeIdSuffix( + this._chart.headers.get('lnobj') || '', + this._base + ) this._channelMapping = this._mapping this._objects = this._chart.objects.allSorted() } @@ -126,7 +132,7 @@ class BMSNoteBuilder { _handleNormalNote(object: BMSObject) { const channel = this._normalizeChannel(object.channel) const beat = this._getBeat(object) - if (object.value.toLowerCase() === this._lnObj) { + if (normalizeIdSuffix(object.value, this._base) === this._lnObj) { if (this._lastNote[channel]) { this._lastNote[channel].endBeat = beat } diff --git a/packages/bms/src/util/id.ts b/packages/bms/src/util/id.ts new file mode 100644 index 000000000..22674572a --- /dev/null +++ b/packages/bms/src/util/id.ts @@ -0,0 +1,34 @@ +// Public: Utilities for dealing with 2-character BMS event/keysound IDs. +// +// BMS charts reference keysounds and events using 2-character IDs. +// Traditionally these IDs are **base-36** (`0-9A-Z`) and are treated +// case-**insensitively** — `AA` and `aa` refer to the same object. +// +// Newer charts may opt in to **base-62** (`0-9a-zA-Z`) by declaring +// `#BASE 62` in the header. In base-62 mode, IDs are case-**sensitive**, +// so `Aa`, `AA`, and `aA` are three different objects. +/* module */ + +/** + * Matches a header command that is indexed by a 2-character ID suffix, + * such as `#WAVxx`, `#BMPxx`, `#BPMxx`, and `#STOPxx`. + * + * Group 1 is the command prefix (always case-insensitive) and group 2 is + * the 2-character ID suffix (whose case matters only in base-62 mode). + */ +export const ID_INDEXED_COMMAND = /^(wav|bmp|bpm|stop)(\S\S)$/i + +/** + * Normalizes a 2-character keysound/event ID suffix according to the chart’s + * numeric base. + * + * - In base-36 (and any other case-insensitive base, e.g. 16) the suffix is + * lowercased, preserving the historical case-insensitive behavior. + * - In base-62 the original case is preserved, making IDs case-sensitive. + * + * @param suffix the raw ID suffix (usually 2 characters) + * @param base the chart’s numeric base (36 by default, 62 for case-sensitive) + */ +export function normalizeIdSuffix(suffix: string, base: number): string { + return base === 62 ? suffix : suffix.toLowerCase() +} From 7caf2ea8582355dd317650a68d58109fd08ede1d Mon Sep 17 00:00:00 2001 From: "dtinth-claw[bot]" <290121326+dtinth-claw[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:00:47 +0700 Subject: [PATCH 2/3] Add changelog entry for base62 keysound-ID support Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014Wv1XChhvMw5LwiVDv3NWh --- changelog/pr-861.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog/pr-861.md diff --git a/changelog/pr-861.md b/changelog/pr-861.md new file mode 100644 index 000000000..2279aec46 --- /dev/null +++ b/changelog/pr-861.md @@ -0,0 +1,8 @@ +--- +author: dtinth +category: feature +type: minor +pr: 861 +--- + +Added support for base62 keysound IDs in BMS charts (`#BASE 62`). From c57667f8da5c56a3624320de4ee5d6eab7f1058e Mon Sep 17 00:00:00 2001 From: "dtinth-claw[bot]" <290121326+dtinth-claw[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:05:52 +0700 Subject: [PATCH 3/3] Make base62 keysound IDs play end-to-end in the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the base62 spike from the bms package into the app so a base62 chart actually plays: keysound IDs must resolve the same way at parse time and at playback time. - bms (notes): store `note.keysound` as the base-canonical ID (`normalizeIdSuffix(object.value, base)`) instead of the raw value, so note IDs and the keysound-map keys agree by construction — lowercased in base-36, case-preserved in base-62, and unchanged for bmson's numeric IDs. - bemuse audio (wave-factory): look up `this._map[keysound]` directly instead of `this._map[keysound.toLowerCase()]`, which was redundant for base-36 and wrong for base-62 (it collapsed case-preserved keys). - bemuse previewer (NotechartPreview): same fix for the preview player's keysound lookup. - bemuse-notechart: add loader specs proving base-36 case folding and base-62 case-sensitivity end-to-end (Reader -> Compiler -> Notes/Keysounds -> Notechart). - examples/base62: add a minimal drag-droppable base62 demo chart. bemuse-notechart's `_generateKeysoundFiles` and `get keysounds()`, plus the coming-soon demo, already build their maps from BMS.Keysounds / canonical IDs, so they need no change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014Wv1XChhvMw5LwiVDv3NWh --- bemuse/src/game/audio/wave-factory.js | 5 ++- bemuse/src/previewer/NotechartPreview.ts | 5 ++- examples/base62/base62-demo.bms | 24 +++++++++++++ .../src/loader/loader.spec.ts | 34 +++++++++++++++++++ packages/bms/src/notes/index.ts | 4 +-- packages/bms/src/notes/note.ts | 5 +++ 6 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 examples/base62/base62-demo.bms diff --git a/bemuse/src/game/audio/wave-factory.js b/bemuse/src/game/audio/wave-factory.js index 2c6cbadac..e7dd70224 100644 --- a/bemuse/src/game/audio/wave-factory.js +++ b/bemuse/src/game/audio/wave-factory.js @@ -25,9 +25,12 @@ export class WaveFactory { // Plays a note _play({ note, delay, exclusive }) { + // `note.keysound` and the keys of `this._map` are both canonical keysound + // IDs produced by the `bms` package (lowercased in base-36, case-preserved + // in base-62), so they can be matched directly without re-normalizing. const keysound = note.keysound if (exclusive) this._stopOldExclusiveSound(keysound, delay) - const filename = this._map[keysound.toLowerCase()] + const filename = this._map[keysound] if (!filename) return null const sample = this._samples[filename] if (!sample) return null diff --git a/bemuse/src/previewer/NotechartPreview.ts b/bemuse/src/previewer/NotechartPreview.ts index f518cef7b..a1bea0510 100644 --- a/bemuse/src/previewer/NotechartPreview.ts +++ b/bemuse/src/previewer/NotechartPreview.ts @@ -364,8 +364,11 @@ class BemuseNotechartPreviewPlayer implements NotechartPreviewPlayer { continue } + // Canonical keysound IDs from the `bms` package: the event's keysound + // and the keys of `_keysounds` already agree (lowercased in base-36, + // case-preserved in base-62), so no re-normalization is needed. const keysound = nextEvent.keysound - const filename = this._keysounds[keysound.toLowerCase()] + const filename = this._keysounds[keysound] if (!filename) { continue } diff --git a/examples/base62/base62-demo.bms b/examples/base62/base62-demo.bms new file mode 100644 index 000000000..349a2c17f --- /dev/null +++ b/examples/base62/base62-demo.bms @@ -0,0 +1,24 @@ +This is a minimal base62 demo chart (discussion #860). + +Lines that do not begin with "#" are ignored by the BMS parser, so this +paragraph is just documentation. + +Because of "#BASE 62", keysound IDs are CASE-SENSITIVE: "Aa", "AA", and "aA" +are three different sounds. On a base-36 chart they would all collapse to the +same sound. Drop this folder into the Bemuse custom-chart / preview area +(alongside the referenced .wav/.ogg files) to hear three distinct keysounds. + +#PLAYER 1 +#GENRE Demo +#TITLE base62 keysound demo +#ARTIST bemuse +#BASE 62 +#BPM 120 +#PLAYLEVEL 1 +#RANK 3 + +#WAVAa kick.wav +#WAVAA snare.wav +#WAVaA hat.wav + +#00111:AaAAaA00 diff --git a/packages/bemuse-notechart/src/loader/loader.spec.ts b/packages/bemuse-notechart/src/loader/loader.spec.ts index 7e492d441..2ee9eb2b3 100644 --- a/packages/bemuse-notechart/src/loader/loader.spec.ts +++ b/packages/bemuse-notechart/src/loader/loader.spec.ts @@ -28,6 +28,40 @@ describe('NotechartLoader', function () { const notechart = await loader.load(buffer, { name: 'wow.bms' }, options) expect(notechart.landmines.length).to.equal(3) }) + + it('folds keysound-ID case for base-36 charts (default)', async function () { + const loader = new NotechartLoader() + const buffer = Buffer.from(`#WAVAA cat.wav\n#00111:AA`) + + const notechart = await loader.load(buffer, { name: 'wow.bms' }, options) + expect(notechart.keysounds).to.deep.equal({ aa: 'cat.wav' }) + // note.keysound is canonical, so a direct map lookup resolves it. + const note = notechart.notes[0] + expect(note.keysound).to.equal('aa') + expect(notechart.keysounds[note.keysound]).to.equal('cat.wav') + }) + + it('keeps base-62 keysound IDs case-sensitive end-to-end', async function () { + const loader = new NotechartLoader() + const buffer = Buffer.from( + `#BASE 62\n#WAVAa lower.wav\n#WAVAA upper.wav\n#00111:AaAA` + ) + + const notechart = await loader.load(buffer, { name: 'wow.bms' }, options) + expect(notechart.keysounds).to.deep.equal({ + Aa: 'lower.wav', + AA: 'upper.wav', + }) + expect(notechart.samples.slice().sort()).to.deep.equal([ + 'lower.wav', + 'upper.wav', + ]) + // Each note's canonical keysound resolves to the correct (case-sensitive) + // file via a direct map lookup — the same lookup the audio layer performs. + const sounds = notechart.notes.map((n) => notechart.keysounds[n.keysound]) + expect(sounds).to.include('lower.wav') + expect(sounds).to.include('upper.wav') + }) }) describe('with bmson file', function () { diff --git a/packages/bms/src/notes/index.ts b/packages/bms/src/notes/index.ts index 516a9e209..15d4a89ef 100644 --- a/packages/bms/src/notes/index.ts +++ b/packages/bms/src/notes/index.ts @@ -140,7 +140,7 @@ class BMSNoteBuilder { const note = { beat: beat, endBeat: undefined, - keysound: object.value, + keysound: normalizeIdSuffix(object.value, this._base), column: this._getColumn(channel), } this._lastNote[channel] = note @@ -159,7 +159,7 @@ class BMSNoteBuilder { } else { this._activeLN[channel] = { beat: beat, - keysound: object.value, + keysound: normalizeIdSuffix(object.value, this._base), column: this._getColumn(channel), } } diff --git a/packages/bms/src/notes/note.ts b/packages/bms/src/notes/note.ts index 494086779..aeafba4d5 100644 --- a/packages/bms/src/notes/note.ts +++ b/packages/bms/src/notes/note.ts @@ -5,6 +5,11 @@ export interface BMSNote { beat: number endBeat?: number column?: string + /** + * The keysound ID, normalized to its canonical form for the chart’s base + * (lowercased in base-36, case-preserved in base-62). This matches the keys + * used by {Keysounds}, so it can be looked up directly. + */ keysound: string /**