From bb92f3cab9dc52b7686615de2ba4e70903e940e7 Mon Sep 17 00:00:00 2001 From: khfahqp Date: Thu, 8 Oct 2026 01:31:43 +0800 Subject: [PATCH 1/2] fix(preview): support Refresh charts with updated Editor core and SV geometry --- docs/refresh-preview.md | 24 ++++++++ native/src/viewer.cpp | 5 ++ package.json | 1 + tests/wasm-preview.test.mjs | 119 ++++++++++++++++++++++++++++++++++++ third_party/wds-editor | 2 +- 5 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 docs/refresh-preview.md create mode 100644 tests/wasm-preview.test.mjs diff --git a/docs/refresh-preview.md b/docs/refresh-preview.md new file mode 100644 index 0000000..71eaa9e --- /dev/null +++ b/docs/refresh-preview.md @@ -0,0 +1,24 @@ +# Refresh chart preview + +The viewer uses the `wds-editor` Refresh core pinned by `third_party/wds-editor` +(commit `6fc30e2`). It automatically recognizes extended CSV geometry without a +manual profile switch. Notes may overhang either side of the twelve-lane stage +or be wider than the stage, but must still overlap a playable lane. + +The same core supports positive SV rows (`type=-1`), nontail holds +(`1100/1101/1110/1111`), authored `900` judgments, native `.wdschart` v6 and the +editor's extended SUS metadata. The browser adapter passes each loaded chart's +SV timeline to stage geometry as well as the core snapshot builder. Loading an +ordinary chart clears the preceding chart's SV timeline. SV changes visual +scrolling, not music or judgment times; nontail holds do not acquire artificial +tail judgments or generated eighths. + +Build and exercise the actual browser WASM and packaged skins: + +```sh +npm run build:lib +npm run test:wasm +``` + +`SIRIUS_VIEWER_WASM_DIR` can point the tests at a host application's copied +`wasm/` directory, verifying the deployed integration uses the same core. diff --git a/native/src/viewer.cpp b/native/src/viewer.cpp index 60561f6..3861757 100644 --- a/native/src/viewer.cpp +++ b/native/src/viewer.cpp @@ -227,6 +227,11 @@ EMSCRIPTEN_KEEPALIVE int wv_load_chart(const char* chart_path, const char* music return 0; } v.last_error.clear(); + // ChartPreviewPanel wires the document's visual SV timeline into stage geometry. + // The snapshot builder already reads the profile and SVs from the new core; + // ribbons/arrows also need this timeline, including clearing it on chart changes. + v.preview.geometry().set_scroll_speed_points(v.engine.document().scroll_speed_points(), + v.engine.document().timing()); v.engine.seek(0); return 1; } diff --git a/package.json b/package.json index bddf5ed..99ef343 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "./assets/*": "./dist-lib/assets/*" }, "scripts": { + "test:wasm": "node --test tests/wasm-preview.test.mjs", "sync:assets": "node ./scripts/sync-assets.mjs", "build:wasm": "node ./scripts/build-wasm.mjs", "dev": "npm run sync:assets && npm run build:wasm && vite", diff --git a/tests/wasm-preview.test.mjs b/tests/wasm-preview.test.mjs new file mode 100644 index 0000000..10a65f4 --- /dev/null +++ b/tests/wasm-preview.test.mjs @@ -0,0 +1,119 @@ +import test, { before, after } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { createServer } from 'node:http' + +// Exercise the built browser WASM and packaged skins directly, also against Net's vendored copy. +const directory = resolve(process.env.SIRIUS_VIEWER_WASM_DIR ?? 'public/wasm') +let module +let generation = 0 +let server +after(() => new Promise(done => server ? server.close(done) : done())) +before(async () => { + const { default: create } = await import(pathToFileURL(resolve(directory, 'sirius-viewer.js')).href) + const data = readFileSync(resolve(directory, 'sirius-viewer.data')) + const wasm = readFileSync(resolve(directory, 'sirius-viewer.wasm')) + server = createServer((request, response) => { + if (request.url !== '/sirius-viewer.wasm') { response.writeHead(404).end(); return } + response.writeHead(200, { 'Content-Type': 'application/wasm' }).end(wasm) + }) + await new Promise(done => server.listen(0, '127.0.0.1', done)) + module = await create({ + locateFile: file => `http://127.0.0.1:${server.address().port}/${file}`, + getPreloadedPackage: () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength), + }) + assert.equal(module._wv_init(1280, 720), 1) + module._wv_clear_texture_queues() + module.FS.mkdirTree('/tests') +}) + +function load(text, extension = 'csv') { + const path = `/tests/chart.${extension}` + module.FS.writeFile(path, text) + return module.ccall('wv_load_chart', 'number', ['string', 'string'], [path, '']) +} +function expectLoad(text, extension) { + assert.equal(load(text, extension), 1, module.UTF8ToString(module._wv_last_error())) +} +function frame(milliseconds) { + module._wv_frame(milliseconds * 1000, 0, ++generation, 0, 0) + const offset = module._wv_vertices() / 4 + const vertices = Array.from(module.HEAPF32.subarray(offset, offset + module._wv_vertex_count() * 10)) + assert(vertices.length > 0 && vertices.every(Number.isFinite), 'frame contains finite render geometry') + assert(module._wv_command_count() > 0) + return vertices +} + +test('CSV preview accepts left/right overhang and notes wider than twelve lanes', () => { + expectLoad('1,-1,10,-1,5,0,0\n2,-1,10,11,6,0,0\n3,-1,10,-2,19,0,0\n') + assert.equal(module._wv_note_count(), 3) + for (const time of [800, 1800, 2800, 4000]) frame(time) + assert.equal(module._wv_combo(), 3) + // A wide note must keep different geometry from a clamped twelve-lane note. + expectLoad('1,-1,10,-2,19,0,0\n') + const wide = frame(800) + expectLoad('1,-1,10,1,12,0,0\n') + assert.notDeepEqual(frame(800), wide) +}) + +test('all nontail hold variants count only authored judgments, with no synthetic tails or eighths', () => { + const holds = [1100, 1101, 1110, 1111].map((type, i) => `1,2,${type},${1 + i * 3},3,0,0\n`).join('') + expectLoad(holds) + frame(1500) + frame(3000) + assert.equal(module._wv_note_count(), 4) + assert.equal(module._wv_combo(), 0) + const judgments = [1, 4, 7, 10].map(lane => `1.5,-1,900,${lane},3,0,0\n`).join('') + expectLoad(holds + judgments) + frame(3000) + assert.equal(module._wv_note_count(), 8) + assert.equal(module._wv_combo(), 4) +}) + +test('SV changes rendered ribbon geometry without changing judgment timing and clears on next chart', () => { + const hold = '1,3,100,3,4,0,0\n' + expectLoad(hold) + const ordinary = frame(1500) + assert.equal(module._wv_combo(), 0) + expectLoad('0,0.5,-1,0,0,0,0\n2,2,-1,0,0,0,0\n' + hold) + assert.notDeepEqual(frame(1500), ordinary) + assert.equal(module._wv_combo(), 0) + frame(3100) + assert.equal(module._wv_combo(), 1) + expectLoad(hold) + assert.deepEqual(frame(1500), ordinary, 'previous SV timeline cannot leak into the next chart') +}) + +test('Refresh wdschart v6 preserves authored nontail judgments and scroll events', () => { + expectLoad(`WDSCHART 6 +BPM 60 +TPQ 480 +TIMING 0 +PROFILE SiriusExtended +NOTES 2 +N 0 480 960 1100 -2 5 0 0 -1 +N 1 720 0 900 -2 5 0 -2147483647 0 +SUPPRESSED_EIGHTHS 0 +SPEEDS 1 +S 0 0.5 +CONCURRENT 0 +END +`, 'wdschart') + assert.equal(module._wv_note_count(), 2) + frame(1500) + frame(3000) + assert.equal(module._wv_combo(), 1) +}) + +test('invalid geometry and SV still fail, and ordinary charts load after failures', () => { + for (const text of ['1,-1,10,14,3,0,0\n', '1,-1,10,-5,2,0,0\n', + '0,0,-1,0,0,0,0\n', '0,-2,-1,0,0,0,0\n', '0,nan,-1,0,0,0,0\n']) { + assert.equal(load(text), 0, text) + assert(module.UTF8ToString(module._wv_last_error()).length > 0) + } + expectLoad('1,-1,10,1,2,0,0\n') + frame(2000) + assert.equal(module._wv_combo(), 1) +}) diff --git a/third_party/wds-editor b/third_party/wds-editor index a3bfa45..6fc30e2 160000 --- a/third_party/wds-editor +++ b/third_party/wds-editor @@ -1 +1 @@ -Subproject commit a3bfa45b907682722a5334270bd1e09f143e6fad +Subproject commit 6fc30e2cb60627a6c9900686adec2fdc636d6e73 From 9ec70f7645dd95feb84f8faf48fffc27e4d0473e Mon Sep 17 00:00:00 2001 From: khfahqp Date: Sat, 10 Oct 2026 12:43:25 +0800 Subject: [PATCH 2/2] fix(viewer): block playback until all preview resources are ready --- docs/loading.md | 23 +++++ package.json | 3 +- src/components/SiriusChartViewer.vue | 140 +++++++++++++-------------- src/lib/audioTransport.ts | 29 +++++- src/lib/sfx.ts | 22 ++--- src/lib/viewer.ts | 2 +- tests/audio-transport.test.mjs | 43 ++++++++ tests/loading.test.mjs | 123 +++++++++++++++++++++++ 8 files changed, 292 insertions(+), 93 deletions(-) create mode 100644 docs/loading.md create mode 100644 tests/audio-transport.test.mjs create mode 100644 tests/loading.test.mjs diff --git a/docs/loading.md b/docs/loading.md new file mode 100644 index 0000000..c2a5828 --- /dev/null +++ b/docs/loading.md @@ -0,0 +1,23 @@ +# Resource loading and playback + +Playback becomes available only after the engine, packaged textures, every hit +sound, the chart, and all supplied music, timing configuration and cover sources +have finished downloading and decoding. Omitting optional sources still supports +silent charts and the stock stage. A supplied resource that fails keeps playback +disabled and displays an error; source failures can be retried in the player. +Engine or bundled-asset failures require remounting the component or reloading the page. + +Changing a source pauses playback immediately. A retired download cannot publish +`loaded` or trigger autoplay for a newer selection. Buttons, canvas clicks, +keyboard shortcuts, exposed `play`/`toggle`/`seek` methods and autoplay share the +same readiness check. Cancelling a play request while the browser resumes its +AudioContext also prevents it from starting later. + +Hosts fetching Blob sources themselves must pass `:pending="loading"` until their +entire source set is available. Set it before replacing the sources. Without this +prop the player cannot distinguish an omitted optional source from one that the +host has not yet provided. `ready` still means engine initialization; +`loaded` is emitted only after the current complete source set is ready. + +Run `npm test` for the loading, transport and native preview regressions, and +`npm run build:lib` to rebuild the distributable Vue component. diff --git a/package.json b/package.json index 99ef343..510ca6e 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "build": "npm run sync:assets && npm run build:wasm && vue-tsc --noEmit && vite build", "typecheck": "vue-tsc --noEmit", "preview": "vite preview", - "build:lib": "npm run sync:assets && npm run build:wasm && node ./scripts/build-lib.mjs" + "build:lib": "npm run sync:assets && npm run build:wasm && node ./scripts/build-lib.mjs", + "test": "node --test tests/*.test.mjs" }, "peerDependencies": { "vue": "^3.4.0" diff --git a/src/components/SiriusChartViewer.vue b/src/components/SiriusChartViewer.vue index 7cfa6c6..2b52696 100644 --- a/src/components/SiriusChartViewer.vue +++ b/src/components/SiriusChartViewer.vue @@ -21,6 +21,8 @@ const props = withDefaults(defineProps<{ assetBase?: string /** Start playing as soon as everything is loaded (subject to browser autoplay rules). */ autoplay?: boolean + /** Host is still fetching sources; all playback stays blocked until false. */ + pending?: boolean /** fetch() options for URL sources (e.g. auth headers). */ fetchInit?: RequestInit }>(), { @@ -31,6 +33,7 @@ const props = withDefaults(defineProps<{ cover: null, assetBase: '/sirius-chart-viewer/', autoplay: false, + pending: false, fetchInit: undefined, }) @@ -62,6 +65,7 @@ let uiRaf = 0 let disposed = false const hasChart = computed(() => !!chartInfo.value) +const canPlay = computed(() => engineReady.value && hasChart.value && !loading.value && !props.pending && !error.value && !disposed) function formatTime(sec: number) { const sign = sec < 0 ? '-' : '' @@ -97,85 +101,69 @@ function fail(e: unknown) { emit('error', error.value) } -async function loadMusic() { - const v = viewer.value - if (!v) return - const bytes = props.music ? await readSource(props.music, props.fetchInit) : null - await v.loadMusic(bytes) -} - -async function loadChart() { - const v = viewer.value - if (!v) return - if (!props.chart) { - chartInfo.value = undefined - return - } - const [data, musicConfig] = await Promise.all([ - readSource(props.chart, props.fetchInit), - props.musicConfig ? readSource(props.musicConfig, props.fetchInit) : Promise.resolve(undefined), - ]) - const name = props.chartName || sourceName(props.chart) || 'chart.csv' - chartInfo.value = v.loadChart({ name, data, musicConfig }) - emit('loaded', chartInfo.value) -} - -async function loadCover() { - const v = viewer.value - if (!v) return - try { - await v.setCover(props.cover ? await readSource(props.cover, props.fetchInit) : null) - } catch (e) { - // A broken jacket must not block playback; keep the stock screens. - console.warn('[SiriusChartViewer] cover:', e) - } -} - -// Source changes are coalesced: a change arriving mid-load is picked up by the -// running loop (music before chart so the timeline knows the BGM length). -const dirty = { music: false, chart: false, cover: false } +// Every generation is a complete source set. A slow retired load can never +// unlock playback or publish a loaded event for a newer selection. +let generation = 0 let running = false +let request: AbortController | null = null -function reload(parts: Partial) { - dirty.music ||= !!parts.music - dirty.chart ||= !!parts.chart - dirty.cover ||= !!parts.cover - if (running || !viewer.value) return - running = true - void runReload().finally(() => (running = false)) +function reload() { + generation++ + request?.abort() + viewer.value?.transport.pause() + playing.value = false + loading.value = true + error.value = '' + if (!viewer.value || running || props.pending || disposed) return + void runReload() } async function runReload() { - loading.value = true - error.value = '' + running = true try { - while ((dirty.music || dirty.chart || dirty.cover) && !disposed) { - if (dirty.music) { - dirty.music = false - await loadMusic() - } else if (dirty.cover) { - dirty.cover = false - await loadCover() - } else { - dirty.chart = false - await loadChart() + while (!disposed && !props.pending) { + const version = generation + const v = viewer.value! + const controller = new AbortController() + request = controller + const current = () => !disposed && version === generation && !props.pending + const init = { ...props.fetchInit, signal: props.fetchInit?.signal + ? AbortSignal.any([controller.signal, props.fetchInit.signal]) : controller.signal } + const chartSource = props.chart + const name = props.chartName || sourceName(chartSource) || 'chart.csv' + try { + const [data, config, audio, jacket] = await Promise.all([ + chartSource ? readSource(chartSource, init) : null, + props.musicConfig ? readSource(props.musicConfig, init) : undefined, + props.music ? readSource(props.music, init) : null, + props.cover ? readSource(props.cover, init) : null, + ]) + if (!current()) continue + await v.loadMusic(audio) + if (!current()) continue + await v.setCover(jacket) + if (!current()) continue + chartInfo.value = data ? v.loadChart({ name, data, musicConfig: config }) : undefined + loading.value = false + if (chartInfo.value) emit('loaded', chartInfo.value) + if (current() && props.autoplay) await play() + } catch (e) { + if (current()) { loading.value = false; fail(e) } } + if (current()) break } - if (props.autoplay && hasChart.value && !disposed) await play() - } catch (e) { - if (!disposed) fail(e) } finally { - loading.value = false + running = false + request = null } } -watch(() => [props.chart, props.chartName, props.musicConfig], () => reload({ chart: true })) -watch(() => props.music, () => reload({ music: true })) -watch(() => props.cover, () => reload({ cover: true })) +watch(() => [props.chart, props.chartName, props.musicConfig, props.music, props.cover, props.pending, props.fetchInit], + reload, { flush: 'sync' }) async function play() { const v = viewer.value - if (!v || !hasChart.value) return + if (!v || !canPlay.value) return try { await v.transport.play() } catch (e) { @@ -193,7 +181,7 @@ function toggle() { } function seek(t: number) { - viewer.value?.transport.seek(t) + if (canPlay.value) viewer.value?.transport.seek(t) } function onSeekInput(e: Event) { @@ -244,10 +232,10 @@ let instance: ChartViewer | null = null onMounted(async () => { uiRaf = requestAnimationFrame(syncUi) - const v = new ChartViewer(canvas.value!) - instance = v - v.transport.onEnded = () => emit('ended') try { + const v = new ChartViewer(canvas.value!) + instance = v + v.transport.onEnded = () => emit('ended') await v.init(props.assetBase) if (disposed) return viewer.value = v @@ -255,7 +243,7 @@ onMounted(async () => { engineReady.value = true emit('ready') loading.value = false - reload({ music: !!props.music, chart: !!props.chart, cover: !!props.cover }) + reload() } catch (e) { loading.value = false if (!disposed) fail(e) @@ -264,6 +252,8 @@ onMounted(async () => { onBeforeUnmount(() => { disposed = true + generation++ + request?.abort() cancelAnimationFrame(uiRaf) instance?.dispose() instance = null @@ -278,28 +268,28 @@ defineExpose({ play, pause, toggle, seek, viewer })
-
+
- {{ engineReady ? '正在加载谱面…' : '正在加载预览引擎…' }} + {{ engineReady ? '正在加载谱面、音乐与画面资源…' : '正在加载预览引擎…' }}
没有谱面
- +