diff --git a/extensions/diorama64/package.json b/extensions/diorama64/package.json index 9a606a1..9f8762a 100644 --- a/extensions/diorama64/package.json +++ b/extensions/diorama64/package.json @@ -7,6 +7,7 @@ ], "dependencies": { "@theia/core": "1.69.0", + "@theia/debug": "1.69.0", "@theia/editor": "1.69.0", "@theia/file-search": "1.69.0", "@theia/filesystem": "1.69.0", @@ -14,7 +15,6 @@ "@theia/monaco": "1.69.0", "@theia/monaco-editor-core": "1.96.302", "@theia/navigator": "1.69.0", - "@theia/debug": "1.69.0", "@theia/outline-view": "1.69.0", "@theia/plugin-ext": "1.69.0", "@theia/preferences": "1.69.0", @@ -24,7 +24,8 @@ "@theia/test": "1.69.0", "@theia/workspace": "1.69.0", "electron-updater": "7.0.0-alpha.7", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "mamar-editor": "^0.1.0" }, "devDependencies": { "@types/js-yaml": "^4.0.9", diff --git a/extensions/diorama64/src/browser/ares/ares-connection.ts b/extensions/diorama64/src/browser/ares/ares-connection.ts new file mode 100644 index 0000000..aaed6ec --- /dev/null +++ b/extensions/diorama64/src/browser/ares/ares-connection.ts @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: 2026 Star Haven contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { injectable, postConstruct } from "@theia/core/shared/inversify"; +import { Emitter, Event } from "@theia/core/lib/common"; + +/** + * Message kinds headless ares's socket speaks, with little-endian integers + * (see nix/ares/0003-headless.patch and nix/ares/0005-headless-memory.patch). + */ +const FRAME_MARKER = 0x46; // 'F' +const FRAME_HEADER_SIZE = 17; +const AUDIO_MARKER = 0x41; // 'A' +const AUDIO_HEADER_SIZE = 9; +const MEMORY_MARKER = 0x4d; // 'M' +const MEMORY_HEADER_SIZE = 9; + +/** How long a memory read waits for ares, which might have exited. */ +const READ_TIMEOUT_MS = 2000; + +export interface AresFrame { + width: number; + height: number; + /** The size the frame is meant to be shown at, after the pixel aspect ratio. */ + displayWidth: number; + displayHeight: number; + rgba: Uint8Array; +} + +interface PendingRead { + resolve(data: Uint8Array): void; + reject(error: Error): void; + timeout: ReturnType; +} + +/** + * The renderer's end of headless ares's socket, which the Electron main + * process relays (see electron-main/game-view-electron-main.ts). Plays the + * audio ares streams, and shares the rest between the Game view and the + * song player. + */ +@injectable() +export class AresConnection { + protected incoming: Uint8Array = new Uint8Array(0); + + protected audioContext?: AudioContext; + /** Where the next scheduled audio buffer should start, in AudioContext time. */ + protected nextAudioTime = 0; + + protected nextReadId = 0; + protected readonly pendingReads = new Map(); + + protected readonly onFrameEmitter = new Emitter(); + readonly onFrame: Event = this.onFrameEmitter.event; + + @postConstruct() + protected init(): void { + window.diorama64GameView.onFrame((data) => this.appendAndParse(data)); + } + + send(message: Uint8Array): void { + window.diorama64GameView.sendInput(message); + } + + /** Stops or starts ares sending video, which nothing might be showing. */ + setVideoEnabled(enabled: boolean): void { + this.send(new Uint8Array([0x56 /* 'V' */, enabled ? 1 : 0])); + } + + /** Writes bytes to the emulated system's memory, in memory order. */ + writeMemory(address: number, data: Uint8Array): void { + const message = new Uint8Array(9 + data.length); + const view = new DataView(message.buffer); + message[0] = 0x57; // 'W' + view.setUint32(1, address, true); + view.setUint32(5, data.length, true); + message.set(data, 9); + this.send(message); + } + + writeU32(address: number, value: number): void { + const data = new Uint8Array(4); + new DataView(data.buffer).setUint32(0, value >>> 0, false); + this.writeMemory(address, data); + } + + /** Reads bytes from the emulated system's memory, in memory order. */ + readMemory(address: number, size: number): Promise { + const id = this.nextReadId++ >>> 0; + const message = new Uint8Array(13); + const view = new DataView(message.buffer); + message[0] = 0x52; // 'R' + view.setUint32(1, id, true); + view.setUint32(5, address, true); + view.setUint32(9, size, true); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingReads.delete(id); + reject(new Error("ares didn't answer a memory read")); + }, READ_TIMEOUT_MS); + this.pendingReads.set(id, { resolve, reject, timeout }); + this.send(message); + }); + } + + async readU32(address: number): Promise { + const data = await this.readMemory(address, 4); + if (data.length < 4) { + throw new Error(`ares couldn't read 0x${address.toString(16)}`); + } + return new DataView(data.buffer, data.byteOffset).getUint32(0, false); + } + + /** + * Accumulates raw socket bytes (chunked arbitrarily by the OS/TCP, not by + * message boundary) and slices out complete messages as they arrive. + */ + protected appendAndParse(chunk: Uint8Array): void { + const combined = new Uint8Array(this.incoming.length + chunk.length); + combined.set(this.incoming, 0); + combined.set(chunk, this.incoming.length); + this.incoming = combined; + + for (;;) { + if (this.incoming.length < 1) { + break; + } + const view = new DataView( + this.incoming.buffer, + this.incoming.byteOffset, + this.incoming.byteLength, + ); + + if (this.incoming[0] === FRAME_MARKER) { + if (this.incoming.length < FRAME_HEADER_SIZE) { + break; + } + const width = view.getUint32(1, true); + const height = view.getUint32(5, true); + const total = FRAME_HEADER_SIZE + width * height * 4; + if (this.incoming.length < total) { + break; + } + this.onFrameEmitter.fire({ + width, + height, + displayWidth: view.getUint32(9, true), + displayHeight: view.getUint32(13, true), + rgba: this.incoming.subarray(FRAME_HEADER_SIZE, total), + }); + this.incoming = this.incoming.subarray(total); + } else if (this.incoming[0] === AUDIO_MARKER) { + if (this.incoming.length < AUDIO_HEADER_SIZE) { + break; + } + const sampleRate = view.getUint32(1, true); + const frameCount = view.getUint32(5, true); + const total = AUDIO_HEADER_SIZE + frameCount * 4; + if (this.incoming.length < total) { + break; + } + this.playAudio( + sampleRate, + frameCount, + this.incoming.subarray(AUDIO_HEADER_SIZE, total), + ); + this.incoming = this.incoming.subarray(total); + } else if (this.incoming[0] === MEMORY_MARKER) { + if (this.incoming.length < MEMORY_HEADER_SIZE) { + break; + } + const id = view.getUint32(1, true); + const total = MEMORY_HEADER_SIZE + view.getUint32(5, true); + if (this.incoming.length < total) { + break; + } + const pending = this.pendingReads.get(id); + if (pending) { + this.pendingReads.delete(id); + clearTimeout(pending.timeout); + pending.resolve(this.incoming.slice(MEMORY_HEADER_SIZE, total)); + } + this.incoming = this.incoming.subarray(total); + } else { + this.incoming = this.incoming.subarray(1); + } + } + } + + /** Schedules one batch of interleaved 16-bit stereo PCM back-to-back with whatever's already queued. */ + protected playAudio( + sampleRate: number, + frameCount: number, + pcm: Uint8Array, + ): void { + if (!this.audioContext) { + this.audioContext = new AudioContext(); + } + const context = this.audioContext; + if (context.state === "suspended") { + context.resume(); + } + + const buffer = context.createBuffer(2, frameCount, sampleRate); + const left = buffer.getChannelData(0); + const right = buffer.getChannelData(1); + const view = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength); + for (let i = 0; i < frameCount; i++) { + left[i] = view.getInt16(i * 4, true) / 32768; + right[i] = view.getInt16(i * 4 + 2, true) / 32768; + } + + const source = context.createBufferSource(); + source.buffer = buffer; + source.connect(context.destination); + + const startTime = Math.max(this.nextAudioTime, context.currentTime); + source.start(startTime); + this.nextAudioTime = startTime + frameCount / sampleRate; + } +} diff --git a/extensions/diorama64/src/browser/ares/ares-session.ts b/extensions/diorama64/src/browser/ares/ares-session.ts new file mode 100644 index 0000000..1a4832d --- /dev/null +++ b/extensions/diorama64/src/browser/ares/ares-session.ts @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2026 Star Haven contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { inject, injectable } from "@theia/core/shared/inversify"; +import { Emitter, Event } from "@theia/core/lib/common"; +import { DebugSessionManager } from "@theia/debug/lib/browser/debug-session-manager"; +import { WorkspaceService } from "@theia/workspace/lib/browser"; +import { + DEBUG_CONFIGURATION_NAME, + PaperMarioDxDebugServer, +} from "../../common/debug-protocol"; + +/** What ares is running the mod for: playing the game, or previewing songs. */ +export type AresOwner = "game" | "music"; + +/** + * Which of the game and the song editor ares is running for. There's one + * ares, so starting either replaces the other, and each stops ares only while + * it's the owner. + */ +@injectable() +export class AresSession { + @inject(PaperMarioDxDebugServer) + protected readonly debugServer!: PaperMarioDxDebugServer; + + @inject(DebugSessionManager) + protected readonly debugSessionManager!: DebugSessionManager; + + @inject(WorkspaceService) + protected readonly workspaceService!: WorkspaceService; + + protected currentOwner?: AresOwner; + + protected readonly onDidChangeOwnerEmitter = new Emitter< + AresOwner | undefined + >(); + readonly onDidChangeOwner: Event = + this.onDidChangeOwnerEmitter.event; + + get owner(): AresOwner | undefined { + return this.currentOwner; + } + + /** Records that ares is starting for the game, replacing any song preview. */ + claimForGame(): void { + this.setOwner("game"); + } + + /** + * Boots the mod's built ROM for previewing songs, ending the game and its + * debug session if they're running. + */ + async startMusic(): Promise { + const root = this.workspaceService.tryGetRoots()[0]; + if (!root) { + throw new Error("No mod is open"); + } + + // Before the debug session ends, so the game's cleanup leaves ares alone. + this.setOwner("music"); + const session = this.debugSessionManager.sessions.find( + (s) => s.configuration.name === DEBUG_CONFIGURATION_NAME, + ); + if (session) { + await this.debugSessionManager.terminateSession(session); + } + await this.debugServer.runRom(root.resource.toString()); + } + + /** Stops ares if it's running for `owner`. */ + async stop(owner: AresOwner): Promise { + if (this.currentOwner !== owner) { + return; + } + this.setOwner(undefined); + await this.debugServer.killAres(); + } + + protected setOwner(owner: AresOwner | undefined): void { + if (this.currentOwner !== owner) { + this.currentOwner = owner; + this.onDidChangeOwnerEmitter.fire(owner); + } + } +} diff --git a/extensions/diorama64/src/browser/bgm-editor/bgm-editor-contribution.ts b/extensions/diorama64/src/browser/bgm-editor/bgm-editor-contribution.ts new file mode 100644 index 0000000..07f8a5e --- /dev/null +++ b/extensions/diorama64/src/browser/bgm-editor/bgm-editor-contribution.ts @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 Star Haven contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { inject, injectable, interfaces } from "@theia/core/shared/inversify"; +import { + ApplicationShell, + NavigatableWidgetOpenHandler, + NavigatableWidgetOptions, + UndoRedoHandler, + WidgetFactory, +} from "@theia/core/lib/browser"; +import URI from "@theia/core/lib/common/uri"; +import { FileService } from "@theia/filesystem/lib/browser/file-service"; +import { load } from "js-yaml"; +import { ProjectLayers } from "../project/project-layers"; +import { BgmEditorWidget, BgmEditorWidgetOptions } from "./bgm-editor-widget"; + +@injectable() +export class BgmEditorOpenHandler extends NavigatableWidgetOpenHandler { + readonly id = BgmEditorWidget.FACTORY_ID; + readonly label = "Song Editor"; + + canHandle(uri: URI): number { + return uri.path.ext === ".bgm" ? 500 : 0; + } +} + +/** Creates each song editor with its file's contents already read. */ +export function createBgmEditorWidgetFactory( + container: interfaces.Container, +): WidgetFactory { + return { + id: BgmEditorWidget.FACTORY_ID, + async createWidget(options: NavigatableWidgetOptions) { + const fileService = container.get(FileService); + const uri = new URI(options.uri); + const [file, bankSong] = await Promise.all([ + fileService.readFile(uri), + findBankSong(uri, fileService, container.get(ProjectLayers)), + ]); + + const child = container.createChild(); + child.bind(BgmEditorWidgetOptions).toConstantValue({ + uri, + data: file.value.buffer, + bankSong, + } satisfies BgmEditorWidgetOptions); + child.bind(BgmEditorWidget).toSelf(); + return child.get(BgmEditorWidget); + }, + }; +} + +/** + * The ID of a song that plays the file, from the sbn.yaml beside it, so the + * preview loads that song's auxiliary banks. -1 if no song plays it. + */ +async function findBankSong( + uri: URI, + fileService: FileService, + layers: ProjectLayers, +): Promise { + await layers.ready; + const virtual = layers.toVirtual(uri); + if (virtual === undefined) { + return -1; + } + const folder = virtual.slice(0, virtual.lastIndexOf("/") + 1); + const sbn = await layers.fileAt(`${folder}sbn.yaml`); + if (!sbn) { + return -1; + } + const config = load((await fileService.read(sbn)).value) as { + songs?: { id?: number | "auto"; file?: string }[]; + }; + return songIdOf(config.songs ?? [], uri.path.base); +} + +/** + * Assigns song IDs the way papermario-dx's tools/build/audio/sbn.py does: + * in ID order, with "auto" (or no ID) taking the next free one at the end. + */ +function songIdOf( + songs: { id?: number | "auto"; file?: string }[], + file: string, +): number { + const sorted = [...songs].sort( + (a, b) => + (typeof a.id === "number" ? a.id : 0xffff) - + (typeof b.id === "number" ? b.id : 0xffff), + ); + const files: (string | undefined)[] = []; + for (const song of sorted) { + const id = typeof song.id === "number" ? song.id : files.length; + files[id] = song.file; + } + return files.indexOf(file); +} + +/** Undo and redo in the focused song editor. */ +@injectable() +export class BgmEditorUndoRedoHandler + implements UndoRedoHandler +{ + // Below the handler for text inputs, which the editor has some of. + readonly priority = 900; + + @inject(ApplicationShell) + protected readonly shell!: ApplicationShell; + + select(): BgmEditorWidget | undefined { + const widget = this.shell.activeWidget; + return widget instanceof BgmEditorWidget ? widget : undefined; + } + + undo(widget: BgmEditorWidget): void { + widget.undo(); + } + + redo(widget: BgmEditorWidget): void { + widget.redo(); + } +} diff --git a/extensions/diorama64/src/browser/bgm-editor/bgm-editor-widget.tsx b/extensions/diorama64/src/browser/bgm-editor/bgm-editor-widget.tsx new file mode 100644 index 0000000..5a41fc4 --- /dev/null +++ b/extensions/diorama64/src/browser/bgm-editor/bgm-editor-widget.tsx @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2026 Star Haven contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import * as React from "react"; +import { inject, injectable } from "@theia/core/shared/inversify"; +import { ReactWidget } from "@theia/core/lib/browser/widgets/react-widget"; +import { + LabelProvider, + Navigatable, + Saveable, + SaveableSource, +} from "@theia/core/lib/browser"; +import { Disposable, Emitter } from "@theia/core/lib/common"; +import { BinaryBuffer } from "@theia/core/lib/common/buffer"; +import URI from "@theia/core/lib/common/uri"; +import { FileService } from "@theia/filesystem/lib/browser/file-service"; +import { BgmEditor, BgmEditorHandle } from "mamar-editor"; +import "mamar-editor/style.css"; +import { ProjectLayers } from "../project/project-layers"; +import { MusicPreview, PreviewSongPlayer } from "./music-preview"; + +export const BgmEditorWidgetOptions = Symbol("BgmEditorWidgetOptions"); +export interface BgmEditorWidgetOptions { + uri: URI; + data: Uint8Array; + bankSong: number; +} + +/** Edits a song (.bgm) with Mamar's editor, playing it in the mod's own ROM. */ +@injectable() +export class BgmEditorWidget + extends ReactWidget + implements SaveableSource, Navigatable +{ + static readonly FACTORY_ID = "diorama64-bgm-editor"; + + protected uri: URI; + protected readonly data: Uint8Array; + protected readonly player: PreviewSongPlayer; + protected readonly editor = React.createRef(); + + protected readonly onDirtyChangedEmitter = new Emitter(); + protected readonly onContentChangedEmitter = new Emitter(); + + protected readonly saveableState = { + dirty: false, + onDirtyChanged: this.onDirtyChangedEmitter.event, + onContentChanged: this.onContentChangedEmitter.event, + save: () => this.save(), + }; + readonly saveable: Saveable = this.saveableState; + + constructor( + @inject(BgmEditorWidgetOptions) options: BgmEditorWidgetOptions, + @inject(FileService) protected readonly fileService: FileService, + @inject(ProjectLayers) protected readonly layers: ProjectLayers, + @inject(LabelProvider) protected readonly labelProvider: LabelProvider, + @inject(MusicPreview) preview: MusicPreview, + ) { + super(); + this.uri = options.uri; + this.data = options.data; + this.player = preview.createPlayer(options.bankSong); + this.id = `${BgmEditorWidget.FACTORY_ID}:${options.uri.toString()}`; + this.title.closable = true; + this.updateTitle(); + this.addClass("diorama64-bgm-editor"); + // Focusable until the editor inside loads. + this.node.tabIndex = -1; + this.toDispose.pushAll([ + this.onDirtyChangedEmitter, + this.onContentChangedEmitter, + Disposable.create(() => this.player.dispose()), + this.labelProvider.onDidChange((event) => { + if (event.affects(this.uri)) { + this.updateTitle(); + } + }), + ]); + this.update(); + } + + getResourceUri(): URI { + return this.uri; + } + + createMoveToUri(resourceUri: URI): URI { + return resourceUri; + } + + undo(): void { + this.editor.current?.undo(); + } + + redo(): void { + this.editor.current?.redo(); + } + + protected onActivateRequest(): void { + const editor = this.node.querySelector("[data-bgm-editor]"); + (editor ?? this.node).focus(); + } + + /** + * Saving a base game song writes the mod's copy, as the build then uses + * that, and the editor carries on with the copy. + */ + protected async save(): Promise { + const handle = this.editor.current; + if (!handle) { + return; + } + const data = handle.save(); + const target = + (this.layers.isBase(this.uri) && this.layers.overrideOf(this.uri)) || + this.uri; + await this.fileService.writeFile(target, BinaryBuffer.wrap(data)); + if (!target.isEqual(this.uri)) { + this.uri = target; + this.updateTitle(); + } + } + + protected updateTitle(): void { + this.title.label = this.uri.path.base; + this.title.iconClass = `${this.labelProvider.getIcon(this.uri)} file-icon`; + this.title.caption = + this.layers.toVirtual(this.uri) ?? this.uri.path.fsPath(); + } + + protected readonly onDirtyChange = (dirty: boolean): void => { + if (this.saveableState.dirty !== dirty) { + this.saveableState.dirty = dirty; + this.onDirtyChangedEmitter.fire(); + } + if (dirty) { + this.onContentChangedEmitter.fire(); + } + }; + + protected render(): React.ReactNode { + return ( + + ); + } +} diff --git a/extensions/diorama64/src/browser/bgm-editor/music-preview.ts b/extensions/diorama64/src/browser/bgm-editor/music-preview.ts new file mode 100644 index 0000000..bb8b22c --- /dev/null +++ b/extensions/diorama64/src/browser/bgm-editor/music-preview.ts @@ -0,0 +1,361 @@ +// SPDX-FileCopyrightText: 2026 Star Haven contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { inject, injectable } from "@theia/core/shared/inversify"; +import { Emitter } from "@theia/core/lib/common"; +import { MessageService } from "@theia/core/lib/common/message-service"; +import { FileService } from "@theia/filesystem/lib/browser/file-service"; +import { WorkspaceService } from "@theia/workspace/lib/browser"; +import type { PlayerStatus, SongPlayer, TrackMute } from "mamar-editor"; +import { ELF_PATH, PaperMarioDxDebugServer } from "../../common/debug-protocol"; +import { AresConnection } from "../ares/ares-connection"; +import { AresSession } from "../ares/ares-session"; +import { BuildRunner } from "../c-language/build-runner"; + +/** The globals dx's music preview mode reads and writes (see src/dx/music_preview.h). */ +const SYMBOLS = [ + "MusicPreviewEnabled", + "MusicPreviewReady", + "MusicPreviewBGM", + "MusicPreviewBGMSize", + "MusicPreviewVariation", + "MusicPreviewBankSong", + "MusicPreviewRequest", + "MusicPreviewPaused", + "MusicPreviewAmbience", + "MusicPreviewTrackMute", + "MusicPreviewTempo", +] as const; + +type Symbols = Record<(typeof SYMBOLS)[number], number>; + +/** Largest encoded BGM dx's preview buffer holds (MUSIC_PREVIEW_MAX_SIZE). */ +const MAX_BGM_SIZE = 0x5000; + +/** MusicPreviewReady's value once the game has booted (MUSIC_PREVIEW_READY). */ +const READY = 0x4d505256; + +const TRACK_MUTE_VALUES: Record = { + none: 0, + mute: 1, + solo: 2, +}; + +/** How long the game gets to boot and enter its music preview mode. */ +const BOOT_TIMEOUT_MS = 20_000; +const BOOT_POLL_MS = 100; +const STATUS_POLL_MS = 100; + +interface Running { + symbols: Symbols; + /** Where the game put its buffer for the song. */ + bgmAddress: number; + request: number; +} + +/** + * Plays songs from open BGM editors in ares, running the mod's built ROM in + * dx's music preview mode. There's one ares, so one editor's player controls + * it at a time: the last to start playing. + */ +@injectable() +export class MusicPreview { + @inject(AresConnection) + protected readonly ares!: AresConnection; + + @inject(AresSession) + protected readonly aresSession!: AresSession; + + @inject(PaperMarioDxDebugServer) + protected readonly debugServer!: PaperMarioDxDebugServer; + + @inject(BuildRunner) + protected readonly buildRunner!: BuildRunner; + + @inject(FileService) + protected readonly fileService!: FileService; + + @inject(WorkspaceService) + protected readonly workspaceService!: WorkspaceService; + + @inject(MessageService) + protected readonly messageService!: MessageService; + + protected running?: Promise; + protected controller?: PreviewSongPlayer; + protected statusTimer?: ReturnType; + + createPlayer(bankSong: number): PreviewSongPlayer { + return new PreviewSongPlayer(this, bankSong); + } + + isController(player: PreviewSongPlayer): boolean { + return this.controller === player; + } + + /** Gives `player` control of ares, booting it if the preview isn't running. */ + async play(player: PreviewSongPlayer): Promise { + const previous = this.controller; + this.controller = player; + if (previous && previous !== player) { + previous.stopped(); + } + + try { + const running = await this.ensureRunning(); + if (this.controller === player) { + this.writeAll(running, player); + this.writeSong(running, player); + this.startStatusPolling(); + } + } catch (error) { + this.controller = undefined; + player.stopped(); + this.messageService.error( + `Could not play the song: ${error instanceof Error ? error.message : error}`, + ); + } + } + + /** Called when `player`'s state changes, which matters only while it's in control. */ + async update(player: PreviewSongPlayer, song: boolean): Promise { + if (this.controller !== player || !this.running) { + return; + } + const running = await this.running; + this.writeAll(running, player); + if (song) { + this.writeSong(running, player); + } + } + + release(player: PreviewSongPlayer): void { + if (this.controller === player) { + this.controller = undefined; + this.stopStatusPolling(); + this.aresSession.stop("music"); + } + } + + protected ensureRunning(): Promise { + if (!this.running || this.aresSession.owner !== "music") { + const running = this.boot(); + this.running = running; + running.catch(() => { + if (this.running === running) { + this.running = undefined; + } + }); + const listener = this.aresSession.onDidChangeOwner((owner) => { + if (owner !== "music" && this.running === running) { + listener.dispose(); + this.running = undefined; + this.stopStatusPolling(); + const controller = this.controller; + this.controller = undefined; + controller?.stopped(); + } + }); + } + return this.running; + } + + protected async boot(): Promise { + const root = this.workspaceService.tryGetRoots()[0]; + if (!root) { + throw new Error("No mod is open."); + } + if (!(await this.fileService.exists(root.resource.resolve(ELF_PATH)))) { + if (!(await this.buildRunner.run({ revealErrors: true }))) { + throw new Error("The mod doesn't build."); + } + } + + const found = await this.debugServer.readSymbols(root.resource.toString(), [ + ...SYMBOLS, + ]); + const missing = SYMBOLS.filter((name) => found[name] === undefined); + if (missing.length > 0) { + throw new Error( + "This mod's version of dx can't preview music. Update dx to play songs from the editor.", + ); + } + const symbols = found as Symbols; + + await this.aresSession.startMusic(); + + // Writing memory while the game boots can stop it booting, so only read + // until it's ready. Reads fail until ares opens its connection. + const deadline = Date.now() + BOOT_TIMEOUT_MS; + const readUntil = async ( + address: number, + done: (value: number) => boolean, + ): Promise => { + while (Date.now() < deadline) { + try { + const value = await this.ares.readU32(address); + if (done(value)) { + return value; + } + } catch { + // Not connected yet. + } + await new Promise((resolve) => setTimeout(resolve, BOOT_POLL_MS)); + } + return undefined; + }; + + if ( + (await readUntil(symbols.MusicPreviewReady, (v) => v === READY)) !== + undefined + ) { + this.ares.setVideoEnabled(false); + this.ares.writeU32(symbols.MusicPreviewEnabled, 1); + const bgmAddress = await readUntil( + symbols.MusicPreviewBGM, + (v) => v !== 0, + ); + if (bgmAddress !== undefined) { + return { symbols, bgmAddress, request: 0 }; + } + } + throw new Error("The game didn't start its music preview mode."); + } + + protected writeAll(running: Running, player: PreviewSongPlayer): void { + const { symbols } = running; + this.ares.writeU32(symbols.MusicPreviewPaused, player.paused ? 1 : 0); + this.ares.writeU32(symbols.MusicPreviewAmbience, player.ambience); + const mutes = new Uint8Array(player.trackMutes.length * 4); + const view = new DataView(mutes.buffer); + player.trackMutes.forEach((mute, i) => + view.setUint32(i * 4, TRACK_MUTE_VALUES[mute], false), + ); + this.ares.writeMemory(symbols.MusicPreviewTrackMute, mutes); + } + + /** Plays the player's song from its start. */ + protected writeSong(running: Running, player: PreviewSongPlayer): void { + const { bgm } = player; + if (!bgm) { + return; + } + if (bgm.length > MAX_BGM_SIZE) { + this.messageService.error( + `The song is too large to play: ${bgm.length} bytes, but the game holds ${MAX_BGM_SIZE}.`, + ); + return; + } + const { symbols } = running; + this.ares.writeMemory(running.bgmAddress, bgm); + this.ares.writeU32(symbols.MusicPreviewBGMSize, bgm.length); + this.ares.writeU32(symbols.MusicPreviewVariation, player.variation); + this.ares.writeU32(symbols.MusicPreviewBankSong, player.bankSong); + running.request++; + this.ares.writeU32(symbols.MusicPreviewRequest, running.request); + } + + protected startStatusPolling(): void { + if (this.statusTimer !== undefined) { + return; + } + let reading = false; + this.statusTimer = setInterval(async () => { + const controller = this.controller; + const running = this.running; + if (reading || !controller || !running) { + return; + } + reading = true; + try { + const { symbols } = await running; + const tempo = await this.ares.readU32(symbols.MusicPreviewTempo); + if (this.controller === controller) { + controller.status({ tempo: tempo / 100 }); + } + } catch { + // ares is restarting or has exited. + } finally { + reading = false; + } + }, STATUS_POLL_MS); + } + + protected stopStatusPolling(): void { + if (this.statusTimer !== undefined) { + clearInterval(this.statusTimer); + this.statusTimer = undefined; + } + } +} + +/** One BGM editor's player, which plays through the shared MusicPreview. */ +export class PreviewSongPlayer implements SongPlayer { + bgm?: Uint8Array; + variation = 0; + paused = true; + /** AMBIENT_SILENCE */ + ambience = 6; + readonly trackMutes: TrackMute[] = new Array(16).fill("none"); + + protected readonly statusEmitter = new Emitter(); + protected readonly stopEmitter = new Emitter(); + + constructor( + protected readonly preview: MusicPreview, + /** The ID of the song in sbn.yaml whose auxiliary banks the song uses, or -1. */ + readonly bankSong: number, + ) {} + + load(bgm: Uint8Array, variation: number): void { + this.bgm = bgm; + this.variation = variation; + this.preview.update(this, true); + } + + setPaused(paused: boolean): void { + this.paused = paused; + if (!paused && !this.preview.isController(this)) { + this.preview.play(this); + } else { + this.preview.update(this, false); + } + } + + setAmbientSound(sound: number): void { + this.ambience = sound; + this.preview.update(this, false); + } + + setTrackMute(track: number, mute: TrackMute): void { + this.trackMutes[track] = mute; + this.preview.update(this, false); + } + + onStatus(listener: (status: PlayerStatus) => void): () => void { + const disposable = this.statusEmitter.event(listener); + return () => disposable.dispose(); + } + + onStop(listener: () => void): () => void { + const disposable = this.stopEmitter.event(listener); + return () => disposable.dispose(); + } + + status(status: PlayerStatus): void { + this.statusEmitter.fire(status); + } + + /** Playback stopped because another player or the game took ares. */ + stopped(): void { + this.paused = true; + this.stopEmitter.fire(); + } + + dispose(): void { + this.preview.release(this); + this.statusEmitter.dispose(); + this.stopEmitter.dispose(); + } +} diff --git a/extensions/diorama64/src/browser/debug/debug-contribution.ts b/extensions/diorama64/src/browser/debug/debug-contribution.ts index f0b6554..9919d31 100644 --- a/extensions/diorama64/src/browser/debug/debug-contribution.ts +++ b/extensions/diorama64/src/browser/debug/debug-contribution.ts @@ -28,6 +28,7 @@ import { DEBUG_CONFIGURATION_NAME, PaperMarioDxDebugServer, } from "../../common/debug-protocol"; +import { AresSession } from "../ares/ares-session"; import { GameViewContribution } from "../game-view/contribution"; import { pauseForBreakpointChanges } from "./debug-session"; @@ -75,6 +76,9 @@ export class PaperMarioDxDebugContribution @inject(GameViewContribution) protected readonly gameViewContribution!: GameViewContribution; + @inject(AresSession) + protected readonly aresSession!: AresSession; + onStart(): void { this.debugSessionManager.onDidCreateDebugSession((session) => { if (session.configuration.name === DEBUG_CONFIGURATION_NAME) { @@ -83,7 +87,7 @@ export class PaperMarioDxDebugContribution }); this.debugSessionManager.onDidDestroyDebugSession((session) => { if (session.configuration.name === DEBUG_CONFIGURATION_NAME) { - this.debugServer.killAres(); + this.aresSession.stop("game"); this.gameViewContribution.collapseView(); } }); @@ -147,6 +151,7 @@ export class PaperMarioDxDebugContribution if (!success) { return; } + this.aresSession.claimForGame(); await this.debugServer.runRom(root.resource.toString()); await this.gameViewContribution.openView({ activate: true }); } catch (error) { @@ -171,6 +176,7 @@ export class PaperMarioDxDebugContribution if (!current) { return; } + this.aresSession.claimForGame(); await this.debugSessionManager.start(current); await this.gameViewContribution.openView({ activate: true }); } diff --git a/extensions/diorama64/src/browser/diorama64-frontend-module.ts b/extensions/diorama64/src/browser/diorama64-frontend-module.ts index 8ecb119..74c86c3 100644 --- a/extensions/diorama64/src/browser/diorama64-frontend-module.ts +++ b/extensions/diorama64/src/browser/diorama64-frontend-module.ts @@ -86,7 +86,7 @@ import { ProblemTabBarDecorator } from "@theia/markers/lib/browser/problem/probl import { FileTreeDecoratorAdapter } from "@theia/filesystem/lib/browser"; import { ProjectDeleteHandler } from "./project/project-delete-handler"; import { WorkspaceDeleteHandler } from "@theia/workspace/lib/browser/workspace-delete-handler"; -import { UndoRedoHandler } from "@theia/core/lib/browser"; +import { OpenHandler, UndoRedoHandler } from "@theia/core/lib/browser"; import { NavigatorTreeDecorator } from "@theia/navigator/lib/browser/navigator-decorator-service"; import { WorkspaceCommandContribution } from "@theia/workspace/lib/browser/workspace-commands"; import { SidePanelHandler } from "@theia/core/lib/browser/shell/side-panel-handler"; @@ -109,6 +109,14 @@ import { FormatServer, formatPath } from "../common/format-protocol"; import { FormatOnSaveContribution } from "./c-language/format-on-save"; import { BuildOnSaveContribution } from "./c-language/build-on-save"; import { BuildRunner } from "./c-language/build-runner"; +import { AresConnection } from "./ares/ares-connection"; +import { AresSession } from "./ares/ares-session"; +import { MusicPreview } from "./bgm-editor/music-preview"; +import { + BgmEditorOpenHandler, + BgmEditorUndoRedoHandler, + createBgmEditorWidgetFactory, +} from "./bgm-editor/bgm-editor-contribution"; import { ToolchainServer, toolchainPath } from "../common/toolchain-protocol"; import { JjScmService } from "./jj/jj-scm-provider"; import { JjResourceResolver } from "./jj/jj-resource-resolver"; @@ -175,6 +183,17 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { })) .inSingletonScope(); + bind(AresConnection).toSelf().inSingletonScope(); + bind(AresSession).toSelf().inSingletonScope(); + bind(MusicPreview).toSelf().inSingletonScope(); + bind(BgmEditorOpenHandler).toSelf().inSingletonScope(); + bind(OpenHandler).toService(BgmEditorOpenHandler); + bind(WidgetFactory) + .toDynamicValue(({ container }) => createBgmEditorWidgetFactory(container)) + .inSingletonScope(); + bind(BgmEditorUndoRedoHandler).toSelf().inSingletonScope(); + bind(UndoRedoHandler).toService(BgmEditorUndoRedoHandler); + bind(GameControlsCommandContribution).toSelf().inSingletonScope(); bind(CommandContribution).toService(GameControlsCommandContribution); diff --git a/extensions/diorama64/src/browser/game-view/game-view-widget.tsx b/extensions/diorama64/src/browser/game-view/game-view-widget.tsx index e01cfec..ef9e503 100644 --- a/extensions/diorama64/src/browser/game-view/game-view-widget.tsx +++ b/extensions/diorama64/src/browser/game-view/game-view-widget.tsx @@ -10,25 +10,18 @@ import { import { BaseWidget, Message } from "@theia/core/lib/browser/widgets/widget"; import { Disposable } from "@theia/core/lib/common"; import { DebugSessionManager } from "@theia/debug/lib/browser/debug-session-manager"; -import { - PaperMarioDxDebugServer, - DEBUG_CONFIGURATION_NAME, -} from "../../common/debug-protocol"; +import { DEBUG_CONFIGURATION_NAME } from "../../common/debug-protocol"; import { ButtonName, GameViewPreferences, STICK_DIRECTIONS, StickDirection, } from "./game-view-preferences"; +import { AresConnection, AresFrame } from "../ares/ares-connection"; +import { AresSession } from "../ares/ares-session"; export const GAME_VIEW_WIDGET_ID = "papermario-dx-game-view"; -/** Wire message kinds ares's headless socket speaks (see nix/ares/0003-headless.patch). */ -const FRAME_MARKER = 0x46; // 'F' -const FRAME_HEADER_SIZE = 17; -const AUDIO_MARKER = 0x41; // 'A' -const AUDIO_HEADER_SIZE = 9; - /** * Wire IDs the frontend uses for each N64 controller input node — matched * against nall/nall/headless/server.cpp's InputNames and, in turn, @@ -81,24 +74,22 @@ const AXIS_DEADZONE = 0.15; export class GameViewWidget extends BaseWidget { static readonly ID = GAME_VIEW_WIDGET_ID; - @inject(PaperMarioDxDebugServer) - protected readonly debugServer!: PaperMarioDxDebugServer; - @inject(DebugSessionManager) protected readonly debugSessionManager!: DebugSessionManager; @inject(GameViewPreferences) protected readonly preferences!: GameViewPreferences; + @inject(AresConnection) + protected readonly ares!: AresConnection; + + @inject(AresSession) + protected readonly aresSession!: AresSession; + protected canvas!: HTMLCanvasElement; protected context!: CanvasRenderingContext2D; - protected unsubscribe?: () => void; + protected frameListener?: Disposable; protected preferenceListener?: Disposable; - protected incoming: Uint8Array = new Uint8Array(0); - - protected audioContext?: AudioContext; - /** Where the next scheduled audio buffer should start, in AudioContext time. */ - protected nextAudioTime = 0; protected keyboardMap = new Map(); protected keyboardStickMap = new Map(); @@ -139,9 +130,7 @@ export class GameViewWidget extends BaseWidget { this.addEventListener(this.node, "keydown", (e) => this.handleKey(e, true)); this.addEventListener(this.node, "keyup", (e) => this.handleKey(e, false)); - this.unsubscribe = window.diorama64GameView.onFrame((data) => { - this.appendAndParse(data); - }); + this.frameListener = this.ares.onFrame((frame) => this.renderFrame(frame)); const pollGamepad = (): void => { this.updateInputs(); @@ -158,23 +147,21 @@ export class GameViewWidget extends BaseWidget { // Closing the side panel holding the game disposes it without a close // request, so this stops the game instead of onCloseRequest. dispose(): void { - this.debugServer.killAres(); + this.aresSession.stop("game"); const session = this.debugSessionManager.sessions.find( (s) => s.configuration.name === DEBUG_CONFIGURATION_NAME, ); if (session) { this.debugSessionManager.terminateSession(session); } - this.unsubscribe?.(); - this.unsubscribe = undefined; + this.frameListener?.dispose(); + this.frameListener = undefined; this.preferenceListener?.dispose(); this.preferenceListener = undefined; if (this.gamepadPollHandle !== undefined) { cancelAnimationFrame(this.gamepadPollHandle); this.gamepadPollHandle = undefined; } - this.audioContext?.close(); - this.audioContext = undefined; super.dispose(); } @@ -308,109 +295,16 @@ export class GameViewWidget extends BaseWidget { message[0] = 0x49; // 'I' message[1] = button; new DataView(message.buffer).setInt16(2, value, true); - window.diorama64GameView.sendInput(message); - } - - /** - * Accumulates raw socket bytes (chunked arbitrarily by the OS/TCP, not by - * message boundary) and slices out complete frames/audio batches as they - * become available. - */ - protected appendAndParse(chunk: Uint8Array): void { - const combined = new Uint8Array(this.incoming.length + chunk.length); - combined.set(this.incoming, 0); - combined.set(chunk, this.incoming.length); - this.incoming = combined; - - for (;;) { - if (this.incoming.length < 1) { - break; - } - - if (this.incoming[0] === FRAME_MARKER) { - if (this.incoming.length < FRAME_HEADER_SIZE) { - break; - } - const view = new DataView( - this.incoming.buffer, - this.incoming.byteOffset, - ); - const width = view.getUint32(1, true); - const height = view.getUint32(5, true); - const displayWidth = view.getUint32(9, true); - const displayHeight = view.getUint32(13, true); - const total = FRAME_HEADER_SIZE + width * height * 4; - if (this.incoming.length < total) { - break; - } - - const rgba = this.incoming.subarray(FRAME_HEADER_SIZE, total); - this.renderFrame(width, height, displayWidth, displayHeight, rgba); - this.incoming = this.incoming.subarray(total); - } else if (this.incoming[0] === AUDIO_MARKER) { - if (this.incoming.length < AUDIO_HEADER_SIZE) { - break; - } - const view = new DataView( - this.incoming.buffer, - this.incoming.byteOffset, - ); - const sampleRate = view.getUint32(1, true); - const frameCount = view.getUint32(5, true); - const total = AUDIO_HEADER_SIZE + frameCount * 4; - if (this.incoming.length < total) { - break; - } - - const pcm = this.incoming.subarray(AUDIO_HEADER_SIZE, total); - this.playAudio(sampleRate, frameCount, pcm); - this.incoming = this.incoming.subarray(total); - } else { - // Desynced: scan forward for the next plausible marker. - this.incoming = this.incoming.subarray(1); - } - } - } - - /** Schedules one batch of interleaved 16-bit stereo PCM back-to-back with whatever's already queued. */ - protected playAudio( - sampleRate: number, - frameCount: number, - pcm: Uint8Array, - ): void { - if (!this.audioContext) { - this.audioContext = new AudioContext(); - } - const context = this.audioContext; - if (context.state === "suspended") { - context.resume(); - } - - const buffer = context.createBuffer(2, frameCount, sampleRate); - const left = buffer.getChannelData(0); - const right = buffer.getChannelData(1); - const view = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength); - for (let i = 0; i < frameCount; i++) { - left[i] = view.getInt16(i * 4, true) / 32768; - right[i] = view.getInt16(i * 4 + 2, true) / 32768; - } - - const source = context.createBufferSource(); - source.buffer = buffer; - source.connect(context.destination); - - const startTime = Math.max(this.nextAudioTime, context.currentTime); - source.start(startTime); - this.nextAudioTime = startTime + frameCount / sampleRate; + this.ares.send(message); } - protected renderFrame( - width: number, - height: number, - displayWidth: number, - displayHeight: number, - rgba: Uint8Array, - ): void { + protected renderFrame({ + width, + height, + displayWidth, + displayHeight, + rgba, + }: AresFrame): void { if (this.canvas.width !== width || this.canvas.height !== height) { this.canvas.width = width; this.canvas.height = height; diff --git a/extensions/diorama64/src/common/debug-protocol.ts b/extensions/diorama64/src/common/debug-protocol.ts index 2c83b2b..1e81552 100644 --- a/extensions/diorama64/src/common/debug-protocol.ts +++ b/extensions/diorama64/src/common/debug-protocol.ts @@ -33,7 +33,7 @@ export const HEADLESS_IPC_PORT = 9124; * being built. */ export const ROM_PATH = "ver/current/build/papermario.z64"; -const ELF_PATH = "ver/current/build/papermario.elf"; +export const ELF_PATH = "ver/current/build/papermario.elf"; export const DEBUG_CONFIGURATION_NAME = "Debug"; @@ -63,4 +63,12 @@ export interface PaperMarioDxDebugServer { */ runRom(workspaceFolderUri: string): Promise; killAres(): Promise; + /** + * The addresses of the named symbols in the mod's built ELF. Names it lacks + * are left out. + */ + readSymbols( + workspaceFolderUri: string, + names: string[], + ): Promise>; } diff --git a/extensions/diorama64/src/node/debug-adapter-contribution.ts b/extensions/diorama64/src/node/debug-adapter-contribution.ts index 611b526..b77e97d 100644 --- a/extensions/diorama64/src/node/debug-adapter-contribution.ts +++ b/extensions/diorama64/src/node/debug-adapter-contribution.ts @@ -14,11 +14,13 @@ import * as path from "path"; import { DEBUG_CONFIGURATION, DEBUG_CONFIGURATION_NAME, + ELF_PATH, GDB_STUB_PORT, PaperMarioDxDebugServer, ROM_PATH, } from "../common/debug-protocol"; import { killAres, spawnAres } from "./ares"; +import { readElfSymbols } from "./elf-symbols"; /** How long ares gets to boot and open its GDB stub before the session gives up. */ const GDB_STUB_TIMEOUT = 10_000; @@ -215,6 +217,16 @@ export class PaperMarioDxDebugContribution } } + readSymbols( + workspaceFolderUri: string, + names: string[], + ): Promise> { + return readElfSymbols( + path.join(this.workspaceRoot(workspaceFolderUri), ELF_PATH), + names, + ); + } + /** Otherwise ares outlives the backend entirely — orphaned once nothing can reach it. */ onStop(): void { if (this.ares) { diff --git a/extensions/diorama64/src/node/elf-symbols.ts b/extensions/diorama64/src/node/elf-symbols.ts new file mode 100644 index 0000000..b59a782 --- /dev/null +++ b/extensions/diorama64/src/node/elf-symbols.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 Star Haven contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { readFile } from "fs/promises"; + +const SHT_SYMTAB = 2; + +/** + * The addresses of the named symbols in a 32-bit ELF, such as the game's + * papermario.elf. Names the ELF lacks are left out. + */ +export async function readElfSymbols( + elfPath: string, + names: readonly string[], +): Promise> { + const data = await readFile(elfPath); + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + + if (data.readUInt32BE(0) !== 0x7f454c46 || data[4] !== 1) { + throw new Error(`${elfPath} is not a 32-bit ELF`); + } + const littleEndian = data[5] === 1; + const u16 = (offset: number) => view.getUint16(offset, littleEndian); + const u32 = (offset: number) => view.getUint32(offset, littleEndian); + + const sectionsOffset = u32(0x20); + const sectionSize = u16(0x2e); + const sectionCount = u16(0x30); + const section = (index: number) => sectionsOffset + index * sectionSize; + + const wanted = new Set(names); + const found: Record = {}; + + for (let i = 0; i < sectionCount; i++) { + const header = section(i); + if (u32(header + 0x04) !== SHT_SYMTAB) { + continue; + } + const symbolsOffset = u32(header + 0x10); + const symbolsSize = u32(header + 0x14); + const symbolSize = u32(header + 0x24); + const strings = u32(section(u32(header + 0x18)) + 0x10); + + for (let offset = 0; offset < symbolsSize; offset += symbolSize) { + const symbol = symbolsOffset + offset; + const nameStart = strings + u32(symbol); + const nameEnd = data.indexOf(0, nameStart); + const name = data.toString("latin1", nameStart, nameEnd); + if (wanted.has(name)) { + found[name] = u32(symbol + 0x04); + } + } + } + + return found; +} diff --git a/yarn.lock b/yarn.lock index c178784..db252c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6133,6 +6133,11 @@ make-fetch-happen@^10.2.1: socks-proxy-agent "^7.0.0" ssri "^9.0.0" +mamar-editor@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/mamar-editor/-/mamar-editor-0.1.0.tgz#1c692055a939d804714171cb9da89dcdbd6d6a03" + integrity sha512-McKrngy0VE07ySsWdVcZOsC0uFqnNHy981G6VXNhzYcF6DigqzUrJtZAE/0+Zcv65hJZbDEGb4wqu1jPvhPq5Q== + markdown-it-anchor@^9.2.0: version "9.2.0" resolved "https://registry.yarnpkg.com/markdown-it-anchor/-/markdown-it-anchor-9.2.0.tgz#89375d9a2a79336403ab7c4fd36b1965cc45e5c8"