├── .envrc ├── pm64-typegen ├── index.js ├── .gitignore ├── Cargo.toml ├── package.json └── src │ └── main.rs ├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── mamar-web ├── .gitignore ├── src │ ├── app │ │ ├── doc │ │ │ ├── SubsegDetails.module.scss │ │ │ ├── ActiveDoc.module.scss │ │ │ ├── timectx.ts │ │ │ ├── Playhead.module.scss │ │ │ ├── Tracker.module.scss │ │ │ ├── Playhead.tsx │ │ │ ├── SegmentMap.module.scss │ │ │ ├── Ruler.module.scss │ │ │ ├── ActiveDoc.tsx │ │ │ ├── SegmentMap.tsx │ │ │ └── SubsegDetails.tsx │ │ ├── sbn │ │ │ ├── BgmFromSbnPicker.scss │ │ │ ├── worker.ts │ │ │ ├── useDecodedSbn.ts │ │ │ ├── BgmFromSbnPicker.tsx │ │ │ └── songNames.json │ │ ├── Main.tsx │ │ ├── icon │ │ │ ├── LoadingSpinner.tsx │ │ │ └── loadingspinner.svg │ │ ├── InstrumentInput.module.scss │ │ ├── store │ │ │ ├── index.ts │ │ │ ├── segment.ts │ │ │ ├── doc.ts │ │ │ ├── dispatch.ts │ │ │ ├── root.ts │ │ │ ├── bgm.ts │ │ │ └── variation.ts │ │ ├── App.module.scss │ │ ├── NoteInput.module.scss │ │ ├── StringInput.module.scss │ │ ├── special-imports.d.ts │ │ ├── WelcomeScreen.tsx │ │ ├── util │ │ │ ├── Patcher.ts │ │ │ ├── hooks │ │ │ │ ├── useSize.ts │ │ │ │ ├── useSelection.tsx │ │ │ │ ├── useRomData.tsx │ │ │ │ ├── useUndoReducer.ts │ │ │ │ └── useMupen.tsx │ │ │ └── DramView.ts │ │ ├── header │ │ │ ├── SponsorButton.tsx │ │ │ ├── SponsorButton.module.scss │ │ │ ├── Header.scss │ │ │ ├── Header.tsx │ │ │ ├── OpenButton.tsx │ │ │ └── BgmActionGroup.tsx │ │ ├── VerticalDragNumberInput.module.scss │ │ ├── StringInput.tsx │ │ ├── emu │ │ │ ├── TrackControls.module.scss │ │ │ ├── TrackControls.tsx │ │ │ ├── PlaybackControls.module.scss │ │ │ └── PaperMarioRomInputDialog.tsx │ │ ├── ErrorBoundaryView.tsx │ │ ├── index.tsx │ │ ├── analytics.ts │ │ ├── DocTabs.tsx │ │ ├── NoteInput.tsx │ │ ├── index.html │ │ ├── VerticalDragNumberInput.tsx │ │ ├── DocTabs.scss │ │ ├── App.tsx │ │ └── InstrumentInput.tsx │ ├── macos_icon.png │ ├── screenshot.png │ ├── social-banner.png │ ├── service-worker-load.js │ ├── manifest.json │ ├── service-worker.js │ ├── index.html │ ├── logo.svg │ └── index.scss ├── vercel.json ├── .editorconfig ├── .parcelrc ├── tsconfig.json └── package.json ├── .gitattributes ├── pm64 ├── src │ ├── lib.rs │ ├── id.rs │ ├── sbn │ │ ├── en.rs │ │ ├── mod.rs │ │ └── de.rs │ ├── main.rs │ ├── bgm │ │ └── mamar.rs │ └── rw.rs ├── tests │ ├── bin │ │ ├── .gitignore │ │ └── extract.py │ └── readme.md └── Cargo.toml ├── patches ├── build │ └── .gitignore ├── yarn.lock ├── src │ ├── patches.ld │ └── patches.c ├── package.json ├── test.js └── build.py ├── .gitignore ├── .gitmodules ├── .vercelignore ├── rustfmt.toml ├── n64crc ├── Makefile ├── test.html └── n64crc.c ├── Cargo.toml ├── .editorconfig ├── package.json ├── license ├── mamar-wasm-bridge ├── Cargo.toml └── src │ └── lib.rs ├── .vscode ├── settings.json ├── c_cpp_properties.json └── tasks.json ├── flake.nix ├── flake.lock ├── .eslintrc.json └── changelog.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake -------------------------------------------------------------------------------- /pm64-typegen/index.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pm64-typegen/.gitignore: -------------------------------------------------------------------------------- 1 | pm64.d.ts 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: bates64 2 | -------------------------------------------------------------------------------- /mamar-web/.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | *.log 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | patches/build/**/* linguist-generated=true 2 | -------------------------------------------------------------------------------- /pm64/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod bgm; 2 | pub mod id; 3 | mod rw; 4 | pub mod sbn; 5 | -------------------------------------------------------------------------------- /patches/build/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !*.js 4 | !*.mjs 5 | !*.d.ts 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | *.log 3 | node_modules 4 | .parcel-cache 5 | .vercel 6 | .direnv 7 | -------------------------------------------------------------------------------- /mamar-web/src/app/doc/SubsegDetails.module.scss: -------------------------------------------------------------------------------- 1 | .regionName { 2 | margin: 0; 3 | } 4 | -------------------------------------------------------------------------------- /mamar-web/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "silent": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /mamar-web/src/macos_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bates64/mamar/HEAD/mamar-web/src/macos_icon.png -------------------------------------------------------------------------------- /mamar-web/src/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bates64/mamar/HEAD/mamar-web/src/screenshot.png -------------------------------------------------------------------------------- /mamar-web/src/social-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bates64/mamar/HEAD/mamar-web/src/social-banner.png -------------------------------------------------------------------------------- /patches/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "patches/papermario"] 2 | path = patches/papermario 3 | url = git@github.com:pmret/papermario.git 4 | -------------------------------------------------------------------------------- /patches/src/patches.ld: -------------------------------------------------------------------------------- 1 | SECTIONS 2 | { 3 | .text 0x80400000 : { 4 | ../build/patches.o(.text) 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /pm64/tests/bin/.gitignore: -------------------------------------------------------------------------------- 1 | *.bin 2 | *.z64 3 | *.ron 4 | 5 | # debug output from failed tests 6 | *.decoded.txt 7 | *.nonmatching.bin 8 | -------------------------------------------------------------------------------- /.vercelignore: -------------------------------------------------------------------------------- 1 | target 2 | patches/papermario 3 | patches/patches.* 4 | !patches/patches.js 5 | !patches/patches.mjs 6 | !patches/patches.d.ts 7 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 120 2 | comment_width = 120 3 | wrap_comments = true 4 | newline_style = "unix" 5 | use_field_init_shorthand = true 6 | -------------------------------------------------------------------------------- /mamar-web/src/app/doc/ActiveDoc.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | height: calc(100vh - 36px - 38px); 3 | overflow: hidden; 4 | display: grid; 5 | } 6 | -------------------------------------------------------------------------------- /mamar-web/src/app/sbn/BgmFromSbnPicker.scss: -------------------------------------------------------------------------------- 1 | .BgmFromSbnPicker { 2 | div[role="row"]:not([aria-rowindex="1"]) { 3 | cursor: pointer; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /mamar-web/src/app/Main.tsx: -------------------------------------------------------------------------------- 1 | import DocTabs from "./DocTabs" 2 | 3 | export default function Main() { 4 | return 5 | 6 | 7 | } 8 | -------------------------------------------------------------------------------- /pm64/tests/readme.md: -------------------------------------------------------------------------------- 1 | Many of these tests run on data extracted from a vanilla Paper Mario (U) ROM. 2 | Run the following script to extract them: 3 | 4 | $ ./bin/extract.py path/to/papermario.z64 5 | -------------------------------------------------------------------------------- /mamar-web/src/app/icon/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | const src = new URL("./loadingspinner.svg", import.meta.url).href 2 | 3 | export default function LoadingSpinner() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /mamar-web/src/service-worker-load.js: -------------------------------------------------------------------------------- 1 | navigator.serviceWorker 2 | .register(new URL("./service-worker.js", import.meta.url), { type: "module" }) 3 | .then(registration => { 4 | return registration.update() 5 | }) 6 | -------------------------------------------------------------------------------- /mamar-web/src/app/InstrumentInput.module.scss: -------------------------------------------------------------------------------- 1 | .actionButton { 2 | all: unset; 3 | background: unset !important; 4 | cursor: pointer; 5 | 6 | &:hover { 7 | background: #00000020 !important; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /pm64-typegen/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pm64-typegen" 3 | version = "0.1.0" 4 | authors = ["Alex Bates "] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | typescript-type-def = "0.5" 9 | pm64 = { path = "../pm64" } 10 | -------------------------------------------------------------------------------- /pm64/src/id.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic::{AtomicU32, Ordering}; 2 | 3 | pub type Id = u32; 4 | 5 | pub fn gen_id() -> Id { 6 | static NEXT_ID: AtomicU32 = AtomicU32::new(0); 7 | 8 | NEXT_ID.fetch_add(1, Ordering::Relaxed) 9 | } 10 | -------------------------------------------------------------------------------- /mamar-web/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [package.json] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /pm64-typegen/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pm64-typegen", 3 | "version": "0.1.0", 4 | "module": "index.js", 5 | "private": true, 6 | "main": "index.js", 7 | "types": "pm64.d.ts", 8 | "scripts": { 9 | "build": "cargo run" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /mamar-web/src/app/store/index.ts: -------------------------------------------------------------------------------- 1 | export { useRoot } from "./dispatch" 2 | export { useDoc } from "./doc" 3 | export type { Doc } from "./doc" 4 | export { useBgm } from "./bgm" 5 | export { useVariation } from "./variation" 6 | export { useSegment } from "./segment" 7 | -------------------------------------------------------------------------------- /mamar-web/src/app/App.module.scss: -------------------------------------------------------------------------------- 1 | .playbackControlsContainer { 2 | position: absolute; 3 | top: 12px; 4 | left: 50%; 5 | transform: translateX(-50%); 6 | 7 | :global(body.window-controls-overlay) & { 8 | top: env(titlebar-area-height, 12px); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /n64crc/Makefile: -------------------------------------------------------------------------------- 1 | CFLAGS ?= -O2 2 | 3 | all: build/n64crc.mjs build/n64crc 4 | 5 | build/n64crc: n64crc.c 6 | gcc $(CFLAGS) -o $@ $< 7 | 8 | build/n64crc.mjs: n64crc.c 9 | emcc $(CFLAGS) -DEMSCRIPTEN -sEXPORTED_RUNTIME_METHODS=ccall,cwrap -o $@ $< 10 | 11 | clean: 12 | rm -f build/n64crc.mjs build/n64crc 13 | -------------------------------------------------------------------------------- /mamar-web/.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "reporters": [ 4 | "...", 5 | "parcel-reporter-multiple-static-file-copier" 6 | ], 7 | "transformers": { 8 | "jsx:*.svg": ["...", "@parcel/transformer-svg-react"], 9 | "jsx:*": ["..."] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /mamar-web/src/app/NoteInput.module.scss: -------------------------------------------------------------------------------- 1 | .input { 2 | color: inherit; 3 | background: transparent; 4 | border: 0; 5 | font: inherit; 6 | text-align: center; 7 | 8 | cursor: text; 9 | 10 | &:active, 11 | &:focus { 12 | color: #fff; 13 | outline: none; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "mamar-wasm-bridge", 4 | "pm64", 5 | "pm64-typegen", 6 | ] 7 | 8 | [profile.release] 9 | lto = true 10 | #codegen-units = 1 # slow compile, but makes more optimisations possible 11 | opt-level = 3 # could also use "s" to optimise for code size 12 | panic = "abort" 13 | -------------------------------------------------------------------------------- /mamar-web/src/app/StringInput.module.scss: -------------------------------------------------------------------------------- 1 | .input { 2 | color: inherit; 3 | background: transparent; 4 | border: 0; 5 | font: inherit; 6 | text-align: center; 7 | 8 | cursor: text; 9 | 10 | &:active, 11 | &:focus { 12 | color: var(--yellow); 13 | outline: none; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /patches/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "patches", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "build/patches.js", 6 | "module": "build/patches.mjs", 7 | "types": "build/patches.d.ts", 8 | "scripts": { 9 | "build": "python3 build.py", 10 | "build-papermario": "cd papermario && ./install.sh && ./configure us && ninja" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /mamar-web/src/app/special-imports.d.ts: -------------------------------------------------------------------------------- 1 | declare module "jsx:*.svg" { 2 | import { ComponentType, SVGProps } from "react" 3 | 4 | const SVGComponent: ComponentType> 5 | export default SVGComponent 6 | } 7 | 8 | declare module "*.module.scss" { 9 | const styles: { [key: string]: string } 10 | export default styles 11 | } 12 | -------------------------------------------------------------------------------- /mamar-web/src/app/sbn/worker.ts: -------------------------------------------------------------------------------- 1 | import * as WasmBridge from "mamar-wasm-bridge" 2 | 3 | WasmBridge.default().then(() => { 4 | WasmBridge.init_logging() 5 | postMessage("READY") 6 | }) 7 | 8 | onmessage = evt => { 9 | const romData = evt.data as ArrayBuffer 10 | const sbn = WasmBridge.sbn_decode(new Uint8Array(romData)) 11 | postMessage(sbn) 12 | } 13 | -------------------------------------------------------------------------------- /mamar-web/src/app/WelcomeScreen.tsx: -------------------------------------------------------------------------------- 1 | import { View } from "@adobe/react-spectrum" 2 | 3 | import BgmFromSbnPicker from "./sbn/BgmFromSbnPicker" 4 | 5 | export default function WelcomeScreen() { 6 | return 10 | ✨ Welcome to Mamar! ✨ 11 | 12 | 13 | } 14 | -------------------------------------------------------------------------------- /mamar-web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*"], 3 | "compilerOptions": { 4 | "target": "es2021", 5 | "strict": true, 6 | "isolatedModules": true, 7 | "jsx": "preserve", 8 | "moduleResolution": "node", 9 | "module": "ES2020", 10 | "resolveJsonModule": true, 11 | "allowSyntheticDefaultImports": true 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{yml,yaml,ron,nix}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [{package-lock,package}.json] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [Makefile] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /mamar-web/src/app/util/Patcher.ts: -------------------------------------------------------------------------------- 1 | export default class Patcher { 2 | dv: DataView 3 | 4 | constructor(rom: ArrayBuffer) { 5 | this.dv = new DataView(rom) 6 | } 7 | 8 | overwriteFunction(romAddr: number, dataU32: number[]) { 9 | for (let i = 0; i < dataU32.length; i++) { 10 | this.dv.setUint32(romAddr + i * 4, dataU32[i], false) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pm64/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pm64" 3 | version = "0.1.0" 4 | authors = ["Alex Bates "] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | lazy_static = "1" 9 | log = "0.4" 10 | midly = { version = "0.5", optional = true, default-features = false, features = ["std", "alloc"] } 11 | rmp-serde = "1.3.0" 12 | serde = "1" 13 | serde_derive = "1" 14 | typescript-type-def = "0.5" 15 | 16 | [build-dependencies] 17 | typescript-type-def = "0.5" 18 | -------------------------------------------------------------------------------- /mamar-web/src/app/header/SponsorButton.tsx: -------------------------------------------------------------------------------- 1 | import { Heart } from "react-feather" 2 | 3 | import styles from "./SponsorButton.module.scss" 4 | 5 | export default function SponsorButton() { 6 | return 12 | 13 | Support development 14 | 15 | } 16 | -------------------------------------------------------------------------------- /pm64/src/sbn/en.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::io::prelude::*; 3 | 4 | use super::*; 5 | 6 | type Error = io::Error; 7 | 8 | type Result = std::result::Result; 9 | 10 | impl Sbn { 11 | pub fn as_bytes(&self) -> Result> { 12 | let mut encoded = io::Cursor::new(Vec::new()); 13 | self.encode(&mut encoded)?; 14 | Ok(encoded.into_inner()) 15 | } 16 | 17 | pub fn encode(&self, _: &mut W) -> Result<()> { 18 | todo!("SBN encoding") // TODO 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /mamar-web/src/app/doc/timectx.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react" 2 | 3 | export interface Time { 4 | xToTicks(clientX: number): number 5 | ticksToXOffset(ticks: number): number 6 | } 7 | 8 | const TIME_CTX = createContext(null) 9 | 10 | export function useTime(): Time { 11 | const time = useContext(TIME_CTX) 12 | 13 | if (!time) { 14 | throw new Error("TimeProvider missing in tree") 15 | } 16 | 17 | return time 18 | } 19 | 20 | export const TimeProvider = TIME_CTX.Provider 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "mamar-web", 5 | "mamar-wasm-bridge/pkg", 6 | "pm64-typegen", 7 | "patches" 8 | ], 9 | "scripts": { 10 | "preinstall": "cd mamar-wasm-bridge && wasm-pack build -t web && cd ../pm64-typegen && cargo run", 11 | "lint": "cargo fmt --all --check && cd mamar-web && yarn run lint" 12 | }, 13 | "multipleStaticFileCopier": [ 14 | { 15 | "origin": "../node_modules/mupen64plus-web/bin/web", 16 | "destination": "dist/mupen64plus-web" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /mamar-web/src/app/header/SponsorButton.module.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | color: var(--pink); 3 | 4 | display: flex; 5 | align-items: center; 6 | gap: 8px; 7 | 8 | padding: 6px; 9 | 10 | font-weight: 500; 11 | text-decoration: none; 12 | 13 | user-select: none; 14 | 15 | &:active { 16 | transform: translateY(1px); 17 | } 18 | 19 | &:hover { 20 | text-decoration: underline; 21 | 22 | svg { 23 | fill: currentColor; 24 | } 25 | } 26 | 27 | :global(body.window-controls-overlay) & { 28 | display: none; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pm64-typegen/src/main.rs: -------------------------------------------------------------------------------- 1 | use pm64::bgm::Bgm; 2 | use pm64::sbn::Sbn; 3 | use typescript_type_def::*; 4 | 5 | type Api = (Bgm, Sbn); 6 | 7 | fn main() { 8 | let path = concat!(env!("CARGO_MANIFEST_DIR"), "/pm64.d.ts"); 9 | let mut file = std::fs::File::create(path).unwrap(); 10 | 11 | let stats = write_definition_file::<_, Api>( 12 | &mut file, 13 | DefinitionFileOptions { 14 | header: "/* eslint-disable */".into(), 15 | root_namespace: None, 16 | }, 17 | ) 18 | .unwrap(); 19 | 20 | println!("Wrote {} type definitions to {}", stats.type_definitions, path); 21 | } 22 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | Copyright (C) 2024 by Alex Bates 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /mamar-wasm-bridge/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mamar-wasm-bridge" 3 | version = "0.1.0" 4 | authors = ["Alex Bates "] 5 | edition = "2018" 6 | 7 | [lib] 8 | crate-type = ["cdylib", "rlib"] 9 | 10 | [dependencies] 11 | log = "0.4" 12 | serde ={ version = "1.0", features = ["derive"] } 13 | serde-wasm-bindgen = "0.4" 14 | wasm-bindgen = { version = "0.2", features = ["serde-serialize"] } 15 | js-sys = "0.3" 16 | console_error_panic_hook = "0.1.6" 17 | console_log = { version = "0.2", features = ["color"] } 18 | 19 | pm64 = { path = "../pm64", features = ["midly"] } 20 | ron = "0.10.1" 21 | 22 | [dev-dependencies] 23 | wasm-bindgen-test = "0.3.13" 24 | -------------------------------------------------------------------------------- /mamar-web/src/app/VerticalDragNumberInput.module.scss: -------------------------------------------------------------------------------- 1 | .input { 2 | color: inherit; 3 | background: transparent; 4 | border: 0; 5 | font: inherit; 6 | text-align: center; 7 | 8 | cursor: row-resize; 9 | 10 | &:active, 11 | &:focus { 12 | color: var(--spectrum-global-color-gray-900, #fff); 13 | outline: none; 14 | } 15 | 16 | // Hide spinner arrows 17 | appearance: textfield; 18 | &::-webkit-outer-spin-button, 19 | &::-webkit-inner-spin-button { 20 | -webkit-appearance: none; 21 | margin: 0; 22 | } 23 | } 24 | 25 | body:has(.input:active) * { 26 | cursor: row-resize !important; 27 | } 28 | -------------------------------------------------------------------------------- /mamar-web/src/app/util/hooks/useSize.ts: -------------------------------------------------------------------------------- 1 | import useResizeObserver from "@react-hook/resize-observer" 2 | import { useState, useRef, useLayoutEffect, RefObject } from "react" 3 | 4 | export function useSize(): { 5 | width: number | undefined 6 | height: number | undefined 7 | ref: RefObject 8 | } { 9 | const ref = useRef(null) 10 | const [size, setSize] = useState<{ width?: number, height?: number }>({ width: undefined, height: undefined }) 11 | 12 | useLayoutEffect(() => { 13 | if (ref.current) 14 | setSize(ref.current.getBoundingClientRect()) 15 | }, [ref]) 16 | 17 | useResizeObserver(ref, entry => setSize(entry.contentRect)) 18 | 19 | return { width: size.width, height: size.height, ref } 20 | } 21 | -------------------------------------------------------------------------------- /mamar-web/src/app/header/Header.scss: -------------------------------------------------------------------------------- 1 | .Header { 2 | -webkit-app-region: drag; 3 | 4 | h1 { 5 | margin: 0; 6 | 7 | .window-controls-overlay & { 8 | display: none; 9 | } 10 | 11 | &, img { 12 | width: 24px; 13 | height: 24px; 14 | vertical-align: top; 15 | } 16 | } 17 | 18 | h2 { 19 | font-size: 12px; 20 | font-weight: 400; 21 | 22 | position: absolute; 23 | left: 50%; 24 | transform: translateX(-50%); 25 | 26 | color: var(--spectrum-global-color-gray-900); 27 | 28 | user-select: none; 29 | 30 | display: none; 31 | 32 | .window-controls-overlay & { 33 | display: block; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "target": true, 4 | "Cargo.lock": true, 5 | ".parcel-cache": true, 6 | }, 7 | "editor.rulers": [120], 8 | "files.associations": { 9 | "*.mamar": "ron", 10 | }, 11 | 12 | "eslint.format.enable": true, 13 | "editor.formatOnSaveMode": "file", 14 | "[typescript]": { 15 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 16 | "editor.formatOnSave": true, 17 | }, 18 | "[typescriptreact]": { 19 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 20 | "editor.formatOnSave": true, 21 | }, 22 | "[rust]": { 23 | "editor.defaultFormatter": "rust-lang.rust-analyzer", 24 | "editor.formatOnSave": true, 25 | }, 26 | "clang-tidy.blacklist": ["*"] 27 | } 28 | -------------------------------------------------------------------------------- /mamar-web/src/app/StringInput.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react" 2 | 3 | import styles from "./StringInput.module.scss" 4 | 5 | export interface Props { 6 | value: string 7 | onChange: (value: string) => void 8 | id?: string 9 | } 10 | 11 | export default function StringInput({ value, onChange, id }: Props) { 12 | const ref = useRef(null) 13 | 14 | useEffect(() => { 15 | if (ref.current) 16 | ref.current.style.width = `${value.length + 1}ch` 17 | }, [ref, value]) 18 | 19 | return { 26 | const value = evt.target.value 27 | onChange(value) 28 | }} 29 | /> 30 | } 31 | -------------------------------------------------------------------------------- /patches/test.js: -------------------------------------------------------------------------------- 1 | // node test.js && cd papermario && ./diff.py state_step_logos; cd .. 2 | // node test.js && 3 | 4 | const fs = require("fs") 5 | 6 | const patches = require("./build/patches.js") 7 | 8 | class Patcher { 9 | constructor(rom) { 10 | this.dv = new DataView(rom) 11 | } 12 | 13 | overwriteFunction(romAddr, dataU32) { 14 | for (let i = 0; i < dataU32.length; i++) { 15 | this.dv.setUint32(romAddr + i * 4, dataU32[i], false) 16 | } 17 | } 18 | } 19 | 20 | const rom = fs.readFileSync("/Users/alex/roms/papermario.z64").buffer 21 | const patcher = new Patcher(rom) 22 | 23 | patcher.overwriteFunction(0xF4A4, patches.skipIntroLogos) 24 | 25 | //const out = "papermario/ver/us/build/papermario.z64" 26 | const out = "../emulator_puppet/star-rod-mod/out/papermario.z64" 27 | fs.writeFileSync(out, Buffer.from(rom)) 28 | -------------------------------------------------------------------------------- /mamar-web/src/app/doc/Playhead.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | } 4 | 5 | .head { 6 | position: absolute; 7 | bottom: 0; 8 | z-index: 1; 9 | 10 | /* Downward triangle */ 11 | $size: 21px; 12 | $color: color-mix(in srgb, var(--spectrum-global-color-gray-900) 50%, transparent); 13 | width: 0; 14 | height: 0; 15 | border-left: calc($size / 2) solid transparent; 16 | border-right: calc($size / 2) solid transparent; 17 | border-top: calc($size * 0.8) solid $color; 18 | 19 | transform: translateX(calc($size * -0.5)); 20 | 21 | cursor: grab; 22 | will-change: left; 23 | 24 | &::after { 25 | content: ""; 26 | position: fixed; 27 | top: 100%; 28 | left: 50%; 29 | transform: translateX(-50%); 30 | width: 1px; 31 | height: 100vh; 32 | background: $color; 33 | pointer-events: none; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /n64crc/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 31 | -------------------------------------------------------------------------------- /mamar-web/src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Mamar", 3 | "name": "Mamar", 4 | "icons": [ 5 | { 6 | "src": "./macos_icon.png", 7 | "type": "image/png", 8 | "sizes": "1024x1024" 9 | } 10 | ], 11 | "id": "/app?source=pwa", 12 | "start_url": "/app?source=pwa", 13 | "background_color": "#111111", 14 | "display": "standalone", 15 | "display_override": ["window-controls-overlay"], 16 | "scope": "/app", 17 | "theme_color": "#ea7aa1", 18 | "description": "Digital audio workstation, MIDI editor, and music player for Paper Mario on Nintendo 64", 19 | "file_handlers": [ 20 | { 21 | "action": "/app?source=pwa-open", 22 | "accept": { 23 | "audio/midi": [".midi", ".mid", ".rmi"], 24 | "application/octet-stream": [".bgm", ".bin"] 25 | }, 26 | "icons": [], 27 | "launch_type": "single-client" 28 | } 29 | ], 30 | "capture_links": "existing-client-navigate" 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/c_cpp_properties.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 4, 3 | "configurations": [ 4 | { 5 | "name": "papermario", 6 | "browse": { 7 | "limitSymbolsToIncludedHeaders": true, 8 | "path": [ 9 | "${workspaceFolder}/patches/papermario/include", 10 | "${workspaceFolder}/patches/papermario/src" 11 | ] 12 | }, 13 | "includePath": [ 14 | "${workspaceFolder}/patches/papermario/include", 15 | //"${workspaceFolder}/patches/papermario/ver/us/build/include", 16 | "${workspaceFolder}/patches/papermario/src" 17 | //"${workspaceFolder}/patches/papermario/assets/us" 18 | ], 19 | "defines": [ 20 | "F3DEX_GBI_2", 21 | "_LANGUAGE_C", 22 | "_MIPS_SZLONG=32", 23 | "VERSION=us", 24 | "VERSION_US" 25 | ], 26 | "cStandard": "c89", 27 | "cppStandard": "c++17", 28 | "intelliSenseMode": "${default}" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /mamar-web/src/app/sbn/useDecodedSbn.ts: -------------------------------------------------------------------------------- 1 | import { Sbn } from "pm64-typegen" 2 | import { useEffect, useState } from "react" 3 | 4 | export default function useDecodedSbn(romData: ArrayBuffer): Sbn | null { 5 | const [decodedSbn, setDecodedSbn] = useState(null) 6 | 7 | useEffect(() => { 8 | const worker = new Worker(new URL("./worker.ts", import.meta.url), { 9 | type: "module", 10 | }) 11 | 12 | new Promise(resolve => { 13 | worker.addEventListener("message", evt => { 14 | const data = evt.data as Sbn | string 15 | 16 | if (data === "READY") { 17 | worker.postMessage(romData) 18 | } else if (typeof data === "string") { 19 | resolve(new Error(data)) 20 | } else { 21 | resolve(data) 22 | } 23 | }) 24 | }).then(sbn => { 25 | setDecodedSbn(sbn) 26 | }) 27 | }, [romData]) 28 | 29 | if (decodedSbn instanceof Error) { 30 | throw decodedSbn 31 | } 32 | 33 | return decodedSbn 34 | } 35 | -------------------------------------------------------------------------------- /pm64/src/main.rs: -------------------------------------------------------------------------------- 1 | use pm64::bgm::Bgm; 2 | use std::fs::read; 3 | 4 | fn main() -> Result<(), Box> { 5 | let args: Vec = std::env::args().collect(); 6 | if args.len() < 2 { 7 | print_help_and_exit(); 8 | } 9 | match args[1].as_ref() { 10 | #[cfg(feature = "midly")] 11 | "convert" if args.len() == 4 => { 12 | let input = read(&args[2])?; 13 | let bgm = pm64::bgm::midi::to_bgm(&input)?; 14 | 15 | let mut output = std::fs::File::create(&args[3])?; 16 | bgm.encode(&mut output)?; 17 | } 18 | "listinstruments" if args.len() == 3 => { 19 | let input = read(&args[2])?; 20 | let bgm = Bgm::from_bytes(&input)?; 21 | 22 | for instrument in &bgm.instruments { 23 | println!("{:?}", instrument); 24 | } 25 | } 26 | _ => print_help_and_exit(), 27 | } 28 | Ok(()) 29 | } 30 | 31 | fn print_help_and_exit() { 32 | eprintln!("Commands:"); 33 | #[cfg(feature = "midly")] 34 | eprintln!(" convert "); 35 | eprintln!(" listinstruments "); 36 | std::process::exit(1); 37 | } 38 | -------------------------------------------------------------------------------- /mamar-web/src/app/emu/TrackControls.module.scss: -------------------------------------------------------------------------------- 1 | .controls { 2 | display: inline-block; 3 | 4 | border: 1px solid var(--spectrum-global-color-gray-200); 5 | border-radius: 6px; 6 | 7 | button { 8 | --spectrum-actionbutton-border-size: 0; 9 | --spectrum-actionbutton-min-width: 1.7em; 10 | --spectrum-actionbutton-height: 1.5em; 11 | --spectrum-actionbutton-text-color: var(--spectrum-global-color-gray-700); 12 | } 13 | } 14 | 15 | .mute { 16 | --spectrum-actionbutton-background-color-selected: var(--spectrum-global-color-blue-400); 17 | --spectrum-actionbutton-background-color-selected-hover: var(--spectrum-global-color-blue-400); 18 | --spectrum-actionbutton-text-color-selected: white; 19 | 20 | border-top-right-radius: 0; 21 | border-bottom-right-radius: 0; 22 | } 23 | 24 | .solo { 25 | --spectrum-actionbutton-background-color-selected: var(--spectrum-global-color-orange-400); 26 | --spectrum-actionbutton-background-color-selected-hover: var(--spectrum-global-color-orange-400); 27 | --spectrum-actionbutton-text-color-selected: white; 28 | 29 | border-top-left-radius: 0; 30 | border-bottom-left-radius: 0; 31 | 32 | border-left: 1px solid var(--spectrum-global-color-gray-200) !important; 33 | } 34 | -------------------------------------------------------------------------------- /mamar-web/src/app/store/segment.ts: -------------------------------------------------------------------------------- 1 | import { Segment } from "pm64-typegen" 2 | 3 | import { useVariation } from "./variation" 4 | 5 | export type SegmentAction = { 6 | type: "set_loop_iter_count" 7 | iter_count: number 8 | } 9 | 10 | export function segmentReducer(segment: Segment, action: SegmentAction): Segment { 11 | switch (action.type) { 12 | case "set_loop_iter_count": 13 | if (segment.type === "EndLoop") { 14 | return { 15 | ...segment, 16 | iter_count: action.iter_count, 17 | } 18 | } else { 19 | console.warn("Tried to set loop iter count on non-end loop segment") 20 | return segment 21 | } 22 | } 23 | } 24 | 25 | export const useSegment = (id?: number, variationIndex?: number, docId?: string): [Segment | undefined, (action: SegmentAction) => void] => { 26 | const [variation, dispatch] = useVariation(variationIndex, docId) 27 | return [ 28 | variation?.segments.find(s => s.id === id), 29 | action => { 30 | if (id) { 31 | dispatch({ 32 | type: "segment", 33 | id, 34 | action, 35 | }) 36 | } 37 | }, 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /mamar-web/src/app/util/hooks/useSelection.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useState, useContext, ReactNode } from "react" 2 | 3 | interface Selection { 4 | selected: number[] 5 | clear(): void 6 | select(...id: number[]): void 7 | multiSelect(id: number): void // toggle 8 | isSelected(id: number): boolean 9 | } 10 | 11 | const SELECTION_CTX = createContext(null) 12 | 13 | export function SelectionProvider({ children }: { children: ReactNode }) { 14 | const [selected, setSelected] = useState([]) 15 | 16 | return [...prev, id]) 26 | }, 27 | isSelected(id: number) { 28 | return selected.includes(id) 29 | }, 30 | }}> 31 | {children} 32 | 33 | } 34 | 35 | export default function useSelection(): Selection { 36 | const selection = useContext(SELECTION_CTX) 37 | 38 | if (!selection) { 39 | throw new Error("SelectionProvider missing in tree") 40 | } 41 | 42 | return selection 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions-rs/toolchain@v1 16 | with: 17 | toolchain: stable 18 | 19 | - name: Download test data 20 | run: cd pm64/tests/bin && curl -Lo mamar_test_data.zip $TEST_DATA_URL && unzip mamar_test_data.zip 21 | env: 22 | TEST_DATA_URL: https://drive.google.com/uc?export=download&id=1EY_nx_6xtpApgh3qyGivgU0OcpzkKRn0 23 | 24 | - uses: Swatinem/rust-cache@v1 25 | 26 | - name: Run tests 27 | uses: actions-rs/cargo@v1 28 | with: 29 | command: test 30 | args: --verbose --no-fail-fast 31 | lint: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v2 35 | - uses: actions-rs/toolchain@v1 36 | with: 37 | toolchain: nightly 38 | components: clippy, rustfmt 39 | override: true 40 | - uses: actions-rs/cargo@v1 41 | with: 42 | command: check 43 | - uses: jetli/wasm-pack-action@v0.3.0 44 | - run: yarn install 45 | - run: yarn run lint 46 | - uses: actions-rs/clippy-check@v1 47 | with: 48 | token: ${{ secrets.GITHUB_TOKEN }} 49 | args: --all-features 50 | -------------------------------------------------------------------------------- /mamar-web/src/app/header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Grid, Heading, View } from "@adobe/react-spectrum" 2 | 3 | import BgmActionGroup from "./BgmActionGroup" 4 | import SponsorButton from "./SponsorButton" 5 | 6 | import "./Header.scss" 7 | 8 | const logo = new URL("../../logo.svg", import.meta.url).href 9 | 10 | export default function Header() { 11 | return 12 | 16 | 21 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | Mamar 34 | 35 | 36 | 37 | 38 | 39 | 40 | } 41 | -------------------------------------------------------------------------------- /pm64/src/sbn/mod.rs: -------------------------------------------------------------------------------- 1 | use std::num::NonZeroU16; 2 | 3 | use serde_derive::{Deserialize, Serialize}; 4 | use typescript_type_def::TypeDef; 5 | 6 | use crate::bgm::{self, Bgm}; 7 | use crate::rw::*; 8 | 9 | pub mod de; 10 | pub mod en; 11 | 12 | pub const MAGIC: &str = "SBN "; 13 | pub const SBN_START: u64 = 0xF00000; 14 | 15 | #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TypeDef)] 16 | pub struct Sbn { 17 | pub files: Vec, 18 | pub songs: Vec, 19 | } 20 | 21 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TypeDef)] 22 | pub struct File { 23 | pub name: String, 24 | pub data: Vec, 25 | } 26 | 27 | impl File { 28 | pub fn magic(&self) -> std::io::Result { 29 | let mut cursor = std::io::Cursor::new(&self.data); 30 | cursor.read_cstring(4) 31 | } 32 | 33 | pub fn as_bgm(&self) -> Result { 34 | Bgm::from_bytes(&self.data) 35 | } 36 | } 37 | 38 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TypeDef)] 39 | pub struct Song { 40 | pub bgm_file: u16, 41 | 42 | // Q: are these actually BK file indexes or are they just some other kind of data? 43 | pub bk_a_file: Option, 44 | pub bk_b_file: Option, 45 | 46 | /// Always None in original ROM. 47 | pub unk_file: Option, 48 | } 49 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run editor electron app", 6 | "isBackground": true, 7 | "dependsOn": [ 8 | "Compile editor for electron on file change", 9 | "Open electron", 10 | ], 11 | "problemMatcher": [], 12 | "group": { 13 | "kind": "build", 14 | "isDefault": true, 15 | }, 16 | }, 17 | { 18 | "label": "Compile editor for electron on file change", 19 | "type": "npm", 20 | "script": "watch-electron", 21 | "path": "editor/", 22 | "isBackground": true, 23 | "problemMatcher": [ 24 | "$rustc-watch" 25 | ] 26 | }, 27 | { 28 | "label": "Run editor in browser", 29 | "type": "npm", 30 | "script": "start", 31 | "path": "editor/", 32 | "isBackground": true, 33 | "problemMatcher": [ 34 | "$rustc-watch" 35 | ], 36 | }, 37 | { 38 | "label": "Open electron", 39 | "type": "npm", 40 | "script": "start", 41 | "path": "electron/", 42 | "isBackground": true, 43 | "problemMatcher": [], 44 | }, 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /mamar-web/src/app/ErrorBoundaryView.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, View, ViewProps } from "@adobe/react-spectrum" 2 | import AlertCircleFilled from "@spectrum-icons/workflow/AlertCircleFilled" 3 | import { Component } from "react" 4 | 5 | export default class ErrorBoundaryView extends Component { 6 | state: { error: any } = { error: null } 7 | 8 | static getDerivedStateFromError(error: any) { 9 | return { error } 10 | } 11 | 12 | render() { 13 | const { children, ...props } = this.props 14 | 15 | if (this.state.error) { 16 | return 17 | 24 | 25 | 26 | An error occurred 27 | 28 | 29 | {this.state.error.toString()} 30 | 31 | 32 | 33 | 34 | } 35 | 36 | return 37 | {children} 38 | 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /mamar-web/src/service-worker.js: -------------------------------------------------------------------------------- 1 | /* 2 | import { manifest, version } from "@parcel/service-worker" 3 | 4 | import { version as mamarVersion } from "../package.json" 5 | 6 | async function install() { 7 | const cache = await caches.open(version) 8 | await cache.addAll(Array.from(new Set(manifest))) 9 | } 10 | addEventListener("install", evt => evt.waitUntil(install())) 11 | 12 | async function activate() { 13 | const keys = await caches.keys() 14 | await Promise.all( 15 | keys.map(key => key !== version && caches.delete(key)), 16 | ) 17 | 18 | if (mamarVersion !== localStorage.MAMAR_VERSION || process.env === "development") { 19 | console.log("[service worker] Clearing cache") 20 | await Promise.all((await caches.keys()).map(key => caches.delete(key))) 21 | } 22 | } 23 | addEventListener("activate", evt => evt.waitUntil(activate())) 24 | */ 25 | 26 | addEventListener("fetch", evt => { 27 | evt.respondWith((async () => { 28 | /*const r = await caches.match(evt.request) 29 | if (r) { 30 | console.log("[service worker] Cache hit", evt.request.url) 31 | return r 32 | } 33 | 34 | console.error("[service worker] Cache miss", evt.request.url)*/ 35 | 36 | const response = await fetch(evt.request) 37 | /*const cache = await caches.open(version) 38 | console.log(`[service worker] Caching new resource: ${evt.request.url}`) 39 | cache.put(evt.request, response.clone())*/ 40 | return response 41 | })()) 42 | }) 43 | -------------------------------------------------------------------------------- /mamar-web/src/app/util/hooks/useRomData.tsx: -------------------------------------------------------------------------------- 1 | import { DialogContainer } from "@react-spectrum/dialog" 2 | import { get, set } from "idb-keyval" 3 | import { useEffect, ReactNode, useState, useContext, createContext } from "react" 4 | 5 | import PaperMarioRomInputDialog, { isPaperMario } from "../../emu/PaperMarioRomInputDialog" 6 | 7 | const romData = createContext(null) 8 | 9 | export function RomDataProvider({ children }: { children: ReactNode }) { 10 | const [value, setValue] = useState(null) 11 | const [isLoaded, setIsLoaded] = useState(false) 12 | 13 | useEffect(() => { 14 | get("rom_papermario_us").then(data => { 15 | if (romData && isPaperMario(data)) { 16 | setValue(data) 17 | } 18 | 19 | setIsLoaded(true) 20 | }) 21 | }, []) 22 | 23 | useEffect(() => { 24 | if (value) { 25 | set("rom_papermario_us", value) 26 | } 27 | }, [value]) 28 | 29 | return 30 | {}} isDismissable={false} isKeyboardDismissDisabled={true}> 31 | {!value && isLoaded && } 32 | 33 | 34 | {value && children} 35 | 36 | } 37 | 38 | export default function useRomData() { 39 | const value = useContext(romData) 40 | 41 | if (!value) { 42 | throw new Error("useRomData must be used within a RomDataProvider") 43 | } 44 | 45 | return value 46 | } 47 | -------------------------------------------------------------------------------- /mamar-web/src/app/util/hooks/useUndoReducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer, Dispatch, ReducerAction, useReducer } from "react" 2 | 3 | interface Action { 4 | type: string 5 | } 6 | 7 | interface State { 8 | past: S[] 9 | present: S 10 | future: S[] 11 | } 12 | 13 | export const undo = { type: "undo" } 14 | export const redo = { type: "redo" } 15 | 16 | export default function useUndoReducer>( 17 | reducer: R, 18 | initialState: () => S, 19 | ): [State, Dispatch>] { 20 | const undoReducer = (state: State, action: Action) => { 21 | const newPresent = reducer(state.present, action) 22 | 23 | if (action.type === "undo") { 24 | const [newPresent, ...past] = state.past 25 | return { 26 | past, 27 | present: newPresent, 28 | future: [state.present, ...state.future], 29 | } 30 | } 31 | if (action.type === "redo") { 32 | const [newPresent, ...future] = state.future 33 | return { 34 | past: [state.present, ...state.past], 35 | present: newPresent, 36 | future, 37 | } 38 | } 39 | return { 40 | past: [state.present, ...state.past], 41 | present: newPresent, 42 | future: [], 43 | } 44 | } 45 | 46 | return useReducer(undoReducer, undefined, () => { 47 | return { 48 | past: [], 49 | present: typeof initialState === "function" ? initialState() : initialState, 50 | future: [], 51 | } 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /mamar-web/src/app/util/DramView.ts: -------------------------------------------------------------------------------- 1 | import { EmulatorControls } from "mupen64plus-web" 2 | 3 | export default class DramView { 4 | u8: Uint8Array 5 | 6 | constructor(emu: EmulatorControls) { 7 | this.u8 = emu.getDram() 8 | } 9 | 10 | readU8(address: number) { 11 | address = address & 0x00FFFFFF 12 | return this.u8[address] 13 | } 14 | 15 | writeU8(address: number, data: Uint8Array | number) { 16 | address = address & 0x00FFFFFF 17 | if (typeof data === "number") { 18 | this.u8[address] = data 19 | } else { 20 | for (let i = 0; i < data.length; i++) { 21 | this.u8[address + i] = data[i] 22 | } 23 | } 24 | } 25 | 26 | readU32(address: number) { 27 | address = address & 0x00FFFFFF 28 | return this.u8[address] | (this.u8[address + 1] << 8) | (this.u8[address + 2] << 16) | (this.u8[address + 3] << 24) 29 | } 30 | 31 | writeU32(address: number, data: Uint32Array | number) { 32 | address = address & 0x00FFFFFF 33 | if (typeof data === "number") { 34 | this.u8[address] = data & 0xFF 35 | this.u8[address + 1] = (data >> 8) & 0xFF 36 | this.u8[address + 2] = (data >> 16) & 0xFF 37 | this.u8[address + 3] = (data >> 24) & 0xFF 38 | } else { 39 | for (let i = 0; i < data.length; i++) { 40 | this.u8[address + i * 4] = data[i] & 0xFF 41 | this.u8[address + i * 4 + 1] = (data[i] >> 8) & 0xFF 42 | this.u8[address + i * 4 + 2] = (data[i] >> 16) & 0xFF 43 | this.u8[address + i * 4 + 3] = (data[i] >> 24) & 0xFF 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /mamar-web/src/app/index.tsx: -------------------------------------------------------------------------------- 1 | import * as WasmBridge from "mamar-wasm-bridge" 2 | import * as React from "react" 3 | import * as ReactDOM from "react-dom/client" 4 | 5 | import "../service-worker-load.js" 6 | import report from "./analytics" 7 | import App from "./App" 8 | 9 | const rootEl = document.getElementById("root") as HTMLElement 10 | const root = ReactDOM.createRoot(rootEl) 11 | 12 | export const loading = 13 | 14 | class ErrorBoundary extends React.Component { 15 | state: { error: any } = { error: null } 16 | 17 | static getDerivedStateFromError(error: any) { 18 | return { error } 19 | } 20 | 21 | render() { 22 | if (this.state.error) { 23 | const errorMessage = this.state.error.stack?.toString?.() || this.state.error.toString() 24 | 25 | return 26 | 27 | Something went wrong. 28 | 29 | An error occurred loading Mamar. If you think this is a bug, please report it. 30 | 31 | 32 | {errorMessage} 33 | 34 | 35 | 36 | } 37 | 38 | return 39 | 40 | 41 | } 42 | } 43 | 44 | report() 45 | 46 | WasmBridge.default().then(() => { 47 | WasmBridge.init_logging() 48 | root.render() 49 | }) 50 | 51 | if (process.env.NODE_ENV !== "production") { 52 | import("@axe-core/react").then((axe: any) => axe(React, ReactDOM, 1000)) 53 | } 54 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Paper Mario music editor"; 3 | inputs = { 4 | flake-schemas.url = "https://flakehub.com/f/DeterminateSystems/flake-schemas/*.tar.gz"; 5 | nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/*.tar.gz"; 6 | rust-overlay = { 7 | url = "github:oxalica/rust-overlay"; 8 | inputs.nixpkgs.follows = "nixpkgs"; 9 | }; 10 | }; 11 | outputs = { self, flake-schemas, nixpkgs, rust-overlay }: 12 | let 13 | overlays = [ 14 | rust-overlay.overlays.default 15 | (final: prev: { 16 | rustToolchain = final.rust-bin.nightly.latest.default.override { 17 | targets = [ "wasm32-unknown-unknown" ]; 18 | extensions = [ "rust-src" ]; 19 | }; 20 | }) 21 | ]; 22 | supportedSystems = [ "x86_64-linux" "aarch64-darwin" "x86_64-darwin" "aarch64-linux" ]; 23 | forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f { 24 | pkgs = import nixpkgs { inherit overlays system; }; 25 | }); 26 | in 27 | { 28 | schemas = flake-schemas.schemas; 29 | devShells = forEachSupportedSystem ({ pkgs }: { 30 | default = pkgs.mkShell { 31 | packages = with pkgs; [ 32 | nodejs-18_x 33 | yarn 34 | rustToolchain 35 | cargo-bloat 36 | cargo-edit 37 | cargo-outdated 38 | cargo-udeps 39 | cargo-watch 40 | rust-analyzer 41 | nixpkgs-fmt 42 | wasm-pack 43 | ]; 44 | env = { 45 | RUST_BACKTRACE = "1"; 46 | }; 47 | shellHook = "yarn install"; 48 | }; 49 | }); 50 | formatter = forEachSupportedSystem ({ pkgs }: pkgs.nixpkgs-fmt); 51 | # TODO: output a package 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /mamar-web/src/app/doc/Tracker.module.scss: -------------------------------------------------------------------------------- 1 | $row-height: 27px; 2 | 3 | .container { 4 | height: 100%; 5 | overflow: overlay; 6 | 7 | background: var(--spectrum-global-color-gray-300); 8 | box-shadow: inset 0 0 16px var(--spectrum-global-color-gray-200); 9 | 10 | ul, li { 11 | list-style: none; 12 | padding: 0; 13 | margin: 0; 14 | } 15 | } 16 | 17 | .lineNumber { 18 | display: inline-block; 19 | position: absolute; 20 | 21 | height: $row-height; 22 | line-height: $row-height; 23 | 24 | text-align: center; 25 | color: var(--spectrum-global-color-gray-500); 26 | font-size: 14px; 27 | font-weight: 600; 28 | user-select: none; 29 | } 30 | 31 | .command { 32 | // Note: can't use --spectrum vars here as dragging brings them out of the Spectrum Provider 33 | 34 | display: inline-block; 35 | 36 | padding: 2px 6px; 37 | height: $row-height; 38 | line-height: 17px; 39 | 40 | color: #ffffffcc; 41 | background: rgb(99, 99, 99); 42 | border: 2px solid #ffffff20; 43 | border-radius: 4px; 44 | 45 | font-size: 14px; 46 | font-weight: 600; 47 | 48 | box-shadow: 0 1px 2px 0 #00000040; 49 | 50 | &.control { 51 | background: #e68619; 52 | } 53 | 54 | &.playback { 55 | background: #9256d9; 56 | } 57 | 58 | &.master { 59 | background: #00a9e0; 60 | } 61 | 62 | &.track { 63 | background: #b93ea6; 64 | } 65 | 66 | &.seg { 67 | background: #22833f; 68 | } 69 | } 70 | 71 | .inputBox { 72 | color: #ffffffdd; 73 | border-radius: 3px; 74 | border: 1px solid #00000020; 75 | background: #00000020; 76 | padding: 0; 77 | margin: 0 6px; 78 | 79 | &:focus { 80 | color: #00000088; 81 | } 82 | 83 | &:has(input[type=number]) { 84 | border-radius: 99px; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /mamar-web/src/app/analytics.ts: -------------------------------------------------------------------------------- 1 | import { Metric, onCLS, onFCP, onFID, onLCP, onTTFB } from "web-vitals" 2 | 3 | declare global { 4 | interface Navigator { 5 | connection: { effectiveType: string } 6 | } 7 | } 8 | 9 | const VITALS_URL = "https://vitals.vercel-analytics.com/v1/vitals" 10 | const VERCEL_ANALYTICS_ID = process.env.VERCEL_ANALYTICS_ID 11 | 12 | function getConnectionSpeed() { 13 | return "connection" in navigator && 14 | navigator["connection"] && 15 | "effectiveType" in navigator["connection"] 16 | ? navigator["connection"]["effectiveType"] 17 | : "" 18 | } 19 | 20 | function sendToAnalytics(metric: Metric) { 21 | if (!VERCEL_ANALYTICS_ID) { 22 | return 23 | } 24 | 25 | const body = { 26 | dsn: VERCEL_ANALYTICS_ID, 27 | id: metric.id, 28 | page: window.location.pathname, 29 | href: window.location.href, 30 | event_name: metric.name, 31 | value: metric.value.toString(), 32 | speed: getConnectionSpeed(), 33 | } 34 | 35 | const blob = new Blob([new URLSearchParams(body).toString()], { 36 | // This content type is necessary for `sendBeacon` 37 | type: "application/x-www-form-urlencoded", 38 | }) 39 | if (navigator.sendBeacon) { 40 | navigator.sendBeacon(VITALS_URL, blob) 41 | } else 42 | fetch(VITALS_URL, { 43 | body: blob, 44 | method: "POST", 45 | credentials: "omit", 46 | keepalive: true, 47 | }) 48 | } 49 | 50 | export default function report() { 51 | try { 52 | onFID(metric => sendToAnalytics(metric)) 53 | onTTFB(metric => sendToAnalytics(metric)) 54 | onLCP(metric => sendToAnalytics(metric)) 55 | onCLS(metric => sendToAnalytics(metric)) 56 | onFCP(metric => sendToAnalytics(metric)) 57 | } catch (err) { 58 | console.error(err) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /mamar-web/src/app/emu/TrackControls.tsx: -------------------------------------------------------------------------------- 1 | import { ToggleButton, Tooltip, TooltipTrigger } from "@adobe/react-spectrum" 2 | import { RAM_MAMAR_trackMute } from "patches" 3 | import { useEffect, useState } from "react" 4 | import { VolumeX, Headphones } from "react-feather" 5 | 6 | import styles from "./TrackControls.module.scss" 7 | 8 | import DramView from "../util/DramView" 9 | import useMupen from "../util/hooks/useMupen" 10 | 11 | export default function TrackControls({ trackIndex }: { trackIndex: number }) { 12 | const { emu } = useMupen() 13 | const [isMute, setIsMute] = useState(false) 14 | const [isSolo, setIsSolo] = useState(false) 15 | 16 | useEffect(() => { 17 | const dram = new DramView(emu) 18 | dram.writeU32(RAM_MAMAR_trackMute + trackIndex * 4, isMute ? 1 : (isSolo ? 2 : 0)) 19 | }, [emu, isMute, isSolo, trackIndex]) 20 | 21 | return 22 | 23 | 29 | 30 | 31 | Toggle mute 32 | 33 | 34 | { 39 | if (solo && isMute) { 40 | setIsMute(false) 41 | } 42 | 43 | setIsSolo(solo) 44 | }} 45 | > 46 | 47 | 48 | Toggle solo (if a track is soloed, only it will play) 49 | 50 | 51 | } 52 | -------------------------------------------------------------------------------- /mamar-web/src/app/emu/PlaybackControls.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | gap: 14px; 6 | 7 | user-select: none; 8 | } 9 | 10 | .actions { 11 | border-radius: 6px; 12 | 13 | &:hover { 14 | background: var(--spectrum-global-color-gray-100); 15 | } 16 | 17 | button { 18 | border: 0; 19 | border-radius: inherit; 20 | 21 | color: var(--spectrum-global-color-gray-700) !important; 22 | background: transparent !important; 23 | 24 | padding: 0 0.6em; 25 | 26 | transition: none; 27 | 28 | svg { 29 | width: 1.5em; 30 | fill: currentColor; 31 | } 32 | 33 | &:active { 34 | background: var(--spectrum-global-color-gray-200) !important; 35 | } 36 | 37 | &.play { 38 | padding-left: 0.7em; 39 | 40 | &[aria-pressed="true"] { 41 | color: #fff !important; 42 | background-color: #43883b !important; 43 | border-color: #43883b !important; 44 | } 45 | } 46 | 47 | } 48 | } 49 | 50 | .position { 51 | border-radius: 6px; 52 | overflow: hidden; 53 | background: var(--spectrum-global-color-gray-200); 54 | 55 | display: flex; 56 | align-items: stretch; 57 | gap: 1px; 58 | 59 | .field { 60 | display: flex; 61 | flex-direction: column-reverse; 62 | 63 | text-align: center; 64 | font-size: 18px; 65 | font-weight: 500; 66 | 67 | color: var(--spectrum-global-color-gray-900); 68 | background: var(--spectrum-global-color-gray-100); 69 | 70 | padding: 2px 14px; 71 | 72 | input { 73 | margin: auto; 74 | } 75 | 76 | .fieldName { 77 | color: var(--spectrum-global-color-gray-700); 78 | font-size: 10px; 79 | text-transform: uppercase; 80 | } 81 | } 82 | } 83 | 84 | // tempo changes so we want a fixed width 85 | .tempo { 86 | min-width: 3em; 87 | } 88 | -------------------------------------------------------------------------------- /mamar-web/src/app/store/doc.ts: -------------------------------------------------------------------------------- 1 | import { Bgm } from "pm64-typegen" 2 | 3 | import { BgmAction, bgmReducer } from "./bgm" 4 | import { useRoot } from "./dispatch" 5 | 6 | export type PanelContent = { 7 | type: "not_open" 8 | } | { 9 | type: "tracker" 10 | trackList: number 11 | track: number 12 | } 13 | 14 | export interface Doc { 15 | id: string 16 | bgm: Bgm 17 | fileHandle?: FileSystemFileHandle 18 | name: string 19 | isSaved: boolean 20 | activeVariation: number 21 | panelContent: PanelContent 22 | } 23 | 24 | export type DocAction = { 25 | type: "bgm" 26 | action: BgmAction 27 | } | { 28 | type: "mark_saved" 29 | fileHandle?: FileSystemFileHandle | null 30 | } | { 31 | type: "set_panel_content" 32 | panelContent: PanelContent 33 | } | { 34 | type: "set_variation" 35 | index: number 36 | } 37 | 38 | export function docReducer(state: Doc, action: DocAction): Doc { 39 | switch (action.type) { 40 | case "bgm": 41 | return { 42 | ...state, 43 | bgm: bgmReducer(state.bgm, action.action), 44 | isSaved: false, 45 | } 46 | case "mark_saved": 47 | return { 48 | ...state, 49 | isSaved: true, 50 | fileHandle: action.fileHandle ?? state.fileHandle, 51 | name: action.fileHandle?.name ?? state.name, 52 | } 53 | case "set_panel_content": 54 | return { 55 | ...state, 56 | panelContent: action.panelContent, 57 | } 58 | case "set_variation": 59 | return { 60 | ...state, 61 | activeVariation: action.index, 62 | } 63 | } 64 | } 65 | 66 | export const useDoc = (id?: string): [Doc | undefined, (action: DocAction) => void] => { 67 | const [root, dispatch] = useRoot() 68 | const trueId = id ?? root.activeDocId 69 | const doc = trueId ? root.docs[trueId] : undefined 70 | const docDispatch = (action: DocAction) => { 71 | if (trueId) { 72 | dispatch({ type: "doc", id: trueId, action }) 73 | } 74 | } 75 | return [doc, docDispatch] 76 | } 77 | -------------------------------------------------------------------------------- /mamar-web/src/app/doc/Playhead.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react" 2 | 3 | import styles from "./Playhead.module.scss" 4 | import { useTime } from "./timectx" 5 | 6 | import { useBgm, useDoc } from "../store" 7 | 8 | function snapToBeat(ticks: number): number { 9 | return Math.round(ticks / 48) * 48 10 | } 11 | 12 | export default function Playhead() { 13 | const { xToTicks, ticksToXOffset } = useTime() 14 | const [ticks, setTicks] = useState(0) 15 | const dragging = useRef(false) 16 | 17 | const [doc] = useDoc() 18 | const [, dispatch] = useBgm() 19 | 20 | useEffect(() => { 21 | function onMouseMove(e: MouseEvent) { 22 | if (!dragging.current) return 23 | let ticks = xToTicks(e.clientX) 24 | if (!e.shiftKey) { 25 | ticks = snapToBeat(ticks) 26 | } 27 | setTicks(ticks) 28 | } 29 | 30 | function onMouseUp() { 31 | dragging.current = false 32 | document.body.style.cursor = "" 33 | } 34 | 35 | window.addEventListener("mousemove", onMouseMove) 36 | window.addEventListener("mouseup", onMouseUp) 37 | return () => { 38 | window.removeEventListener("mousemove", onMouseMove) 39 | window.removeEventListener("mouseup", onMouseUp) 40 | } 41 | }, [xToTicks]) 42 | 43 | if (!doc) return 44 | 45 | return 48 | { 52 | dragging.current = true 53 | document.body.style.cursor = "grab" 54 | }} 55 | onClick={e => { 56 | // TODO: move to a button elsewhere 57 | dispatch({ 58 | type: "split_variation", 59 | variation: doc.activeVariation, 60 | time: ticks, 61 | }) 62 | e.stopPropagation() 63 | }} 64 | > 65 | 66 | 67 | 68 | } 69 | -------------------------------------------------------------------------------- /mamar-web/src/app/emu/PaperMarioRomInputDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Text, Content, Dialog, Divider, Heading, Flex, useDialogContainer } from "@adobe/react-spectrum" 2 | import Alert from "@spectrum-icons/workflow/Alert" 3 | import { useState } from "react" 4 | 5 | function getRomName(romData: ArrayBuffer) { 6 | const romName = new Uint8Array(romData, 0x20, 20) 7 | return String.fromCharCode(...romName) 8 | } 9 | 10 | export function isPaperMario(romData: ArrayBuffer) { 11 | return getRomName(romData) === "PAPER MARIO " 12 | } 13 | 14 | export interface Props { 15 | onChange: (romData: ArrayBuffer) => void 16 | } 17 | 18 | export default function PaperMarioRomInput({ onChange }: Props) { 19 | const [error, setError] = useState(false) 20 | const dialog = useDialogContainer() 21 | 22 | return 23 | ROM required 24 | 25 | 26 | 27 | Mamar requires a clean Paper Mario (US) ROM in z64 format. 28 | Please select a ROM file to continue. 29 | 30 | 31 | { 37 | const file = (evt.target as HTMLInputElement).files?.[0] 38 | const data = await file?.arrayBuffer() 39 | 40 | if (!data || !isPaperMario(data)) { 41 | setError(true) 42 | return 43 | } 44 | 45 | dialog.dismiss() 46 | onChange(data) 47 | }} 48 | /> 49 | {error && 50 | 51 | } 52 | 53 | 54 | 55 | } 56 | -------------------------------------------------------------------------------- /mamar-web/src/app/DocTabs.tsx: -------------------------------------------------------------------------------- 1 | import { Flex } from "@adobe/react-spectrum" 2 | import CircleFilled from "@spectrum-icons/workflow/CircleFilled" 3 | import Close from "@spectrum-icons/workflow/Close" 4 | 5 | import ActiveDoc from "./doc/ActiveDoc" 6 | import ErrorBoundaryView from "./ErrorBoundaryView" 7 | import { Doc, useRoot } from "./store" 8 | 9 | import "./DocTabs.scss" // TODO: use css modules 10 | 11 | function TabButton({ doc }: { doc: Doc }) { 12 | const [root, dispatch] = useRoot() 13 | const { id, name, isSaved } = doc 14 | const isActive = root.activeDocId === id 15 | 16 | return dispatch({ type: "focus_doc", id })} 21 | onAuxClick={() => dispatch({ type: "close_doc", id })} 22 | > 23 | {name} 24 | { 30 | evt.stopPropagation() 31 | dispatch({ type: "close_doc", id }) 32 | }} 33 | onKeyDown={evt => { 34 | if (evt.key === "Enter") { 35 | evt.stopPropagation() 36 | dispatch({ type: "close_doc", id }) 37 | } 38 | }} 39 | > 40 | 41 | {!isSaved && } 42 | 43 | 44 | } 45 | 46 | export default function DocTabs() { 47 | const [root] = useRoot() 48 | const docs = Object.values(root.docs) 49 | 50 | return 51 | {docs.length > 0 && 52 | {docs.map(doc => )} 53 | } 54 | 55 | 56 | 57 | 58 | } 59 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-schemas": { 4 | "locked": { 5 | "lastModified": 1721999734, 6 | "narHash": "sha256-G5CxYeJVm4lcEtaO87LKzOsVnWeTcHGKbKxNamNWgOw=", 7 | "rev": "0a5c42297d870156d9c57d8f99e476b738dcd982", 8 | "revCount": 75, 9 | "type": "tarball", 10 | "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/flake-schemas/0.1.5/0190ef2f-61e0-794b-ba14-e82f225e55e6/source.tar.gz?rev=0a5c42297d870156d9c57d8f99e476b738dcd982&revCount=75" 11 | }, 12 | "original": { 13 | "type": "tarball", 14 | "url": "https://flakehub.com/f/DeterminateSystems/flake-schemas/%2A.tar.gz" 15 | } 16 | }, 17 | "nixpkgs": { 18 | "locked": { 19 | "lastModified": 1744440957, 20 | "narHash": "sha256-FHlSkNqFmPxPJvy+6fNLaNeWnF1lZSgqVCl/eWaJRc4=", 21 | "rev": "26d499fc9f1d567283d5d56fcf367edd815dba1d", 22 | "revCount": 716947, 23 | "type": "tarball", 24 | "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2411.716947%2Brev-26d499fc9f1d567283d5d56fcf367edd815dba1d/01962e50-af41-7ff9-8765-ebb3d39458ba/source.tar.gz?rev=26d499fc9f1d567283d5d56fcf367edd815dba1d&revCount=716947" 25 | }, 26 | "original": { 27 | "type": "tarball", 28 | "url": "https://flakehub.com/f/NixOS/nixpkgs/%2A.tar.gz" 29 | } 30 | }, 31 | "root": { 32 | "inputs": { 33 | "flake-schemas": "flake-schemas", 34 | "nixpkgs": "nixpkgs", 35 | "rust-overlay": "rust-overlay" 36 | } 37 | }, 38 | "rust-overlay": { 39 | "inputs": { 40 | "nixpkgs": [ 41 | "nixpkgs" 42 | ] 43 | }, 44 | "locked": { 45 | "lastModified": 1745289264, 46 | "narHash": "sha256-7nt+UJ7qaIUe2J7BdnEEph9n2eKEwxUwKS/QIr091uA=", 47 | "owner": "oxalica", 48 | "repo": "rust-overlay", 49 | "rev": "3b7171858c20d5293360042936058fb0c4cb93a9", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "oxalica", 54 | "repo": "rust-overlay", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /mamar-web/src/app/store/dispatch.ts: -------------------------------------------------------------------------------- 1 | import { createContainer } from "react-tracked" 2 | import useUndoable from "use-undoable" 3 | 4 | import { Root, RootAction, rootReducer } from "./root" 5 | 6 | interface Dispatch { 7 | (...actions: RootAction[]): void 8 | undo: () => void 9 | redo: () => void 10 | canUndo: boolean 11 | canRedo: boolean 12 | } 13 | 14 | function shouldActionCommitToHistory(action: RootAction): boolean { 15 | switch (action.type) { 16 | case "doc": 17 | switch (action.action.type) { 18 | case "bgm": 19 | return true 20 | } 21 | } 22 | return false 23 | } 24 | 25 | interface Action { 26 | type: string 27 | action?: Action 28 | } 29 | 30 | function joinActionTypes(action: Action): string { 31 | if (action.action) { 32 | return `${action.type}/${joinActionTypes(action.action)}` 33 | } else { 34 | return action.type 35 | } 36 | } 37 | 38 | const { 39 | Provider, 40 | useTracked, 41 | } = createContainer(() => { 42 | const [state, setState, { undo, redo, canUndo, canRedo }] = useUndoable({ 43 | docs: {}, 44 | }, { 45 | behavior: "destroyFuture", // "mergePastReversed", 46 | historyLimit: 100, 47 | }) 48 | 49 | const dispatch: Dispatch = (...actions) => { 50 | console.info("dispatch", actions.map(action => joinActionTypes(action)), actions) 51 | setState( 52 | prevState => { 53 | let newState = prevState 54 | for (const action of actions) { 55 | newState = rootReducer(newState, action) 56 | } 57 | console.log("new state", newState) 58 | return newState 59 | }, 60 | undefined, 61 | actions.map(action => !shouldActionCommitToHistory(action)).reduce((a, b) => a && b, false), 62 | ) 63 | } 64 | dispatch.undo = undo 65 | dispatch.redo = redo 66 | dispatch.canUndo = canUndo 67 | dispatch.canRedo = canRedo 68 | 69 | return [state, dispatch] 70 | }) 71 | 72 | export const RootProvider = Provider 73 | 74 | export const useRoot: () => [Root, Dispatch] = useTracked as any 75 | -------------------------------------------------------------------------------- /mamar-web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mamar-web", 3 | "version": "1.0.0", 4 | "description": "Paper Mario music editor", 5 | "repository": "https://github.com/bates64/mamar", 6 | "author": "Alex Bates ", 7 | "private": true, 8 | "scripts": { 9 | "start": "parcel src/index.html src/app/index.html", 10 | "build": "parcel build src/index.html src/app/index.html", 11 | "lint": "yarn run tsc --noEmit && yarn run eslint ." 12 | }, 13 | "dependencies": { 14 | "@adobe/react-spectrum": "^3.21.2", 15 | "@parcel/service-worker": "^2.7.0", 16 | "@react-hook/resize-observer": "^1.2.6", 17 | "@spectrum-icons/workflow": "^4.0.2", 18 | "@types/stats.js": "^0.17.0", 19 | "browser-fs-access": "^0.31.0", 20 | "classnames": "^2.3.2", 21 | "idb-keyval": "^6.2.0", 22 | "immer": "^9.0.15", 23 | "mamar-wasm-bridge": "*", 24 | "mupen64plus-web": "bates64/mupen64plus-web#mamar", 25 | "patches": "*", 26 | "pm64-typegen": "*", 27 | "react": "^18.2.0", 28 | "react-aria": "^3.19.0", 29 | "react-beautiful-dnd": "^13.1.1", 30 | "react-dom": "^18.2.0", 31 | "react-feather": "^2.0.10", 32 | "react-movable": "^3.0.4", 33 | "react-stately": "^3.17.0", 34 | "react-tracked": "^1.7.10", 35 | "react-window": "^1.8.7", 36 | "use-debounce": "^10.0.4", 37 | "use-undoable": "^3.3.11", 38 | "web-vitals": "^3.0.3" 39 | }, 40 | "devDependencies": { 41 | "@axe-core/react": "^4.4.4", 42 | "@parcel/optimizer-data-url": "2.7.0", 43 | "@parcel/packager-raw-url": "^2.7.0", 44 | "@parcel/transformer-inline-string": "2.7.0", 45 | "@parcel/transformer-sass": "2.7.0", 46 | "@parcel/transformer-svg-react": "^2.7.0", 47 | "@parcel/transformer-webmanifest": "^2.7.0", 48 | "@parcel/watcher": "^2.5.1", 49 | "@types/node": "^18.7.18", 50 | "@types/react": "^18.0.20", 51 | "@types/react-beautiful-dnd": "^13.1.2", 52 | "@types/react-dom": "^18.0.6", 53 | "@types/react-window": "^1.8.5", 54 | "eslint": "^8.23.1", 55 | "eslint-config-react-app": "^7.0.1", 56 | "parcel": "^2.7.0", 57 | "parcel-reporter-multiple-static-file-copier": "^1.0.5", 58 | "process": "^0.11.10", 59 | "typescript": "^4.8.3" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /mamar-web/src/app/NoteInput.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react" 2 | 3 | import styles from "./NoteInput.module.scss" 4 | 5 | const notes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] 6 | 7 | function pitchToNoteName(pitch: number) { 8 | pitch = pitch - 104 9 | const octave = Math.floor(pitch / 12) 10 | const note = notes[pitch % 12] 11 | return `${note}${octave}` 12 | } 13 | 14 | function noteNameToPitch(noteName: string) { 15 | const re = /([A-G])(#|b)?(\d+)/ 16 | const match = noteName.match(re) 17 | if (!match) { 18 | throw new Error(`Invalid note name: ${noteName}`) 19 | } 20 | const [, note, sharp, octave] = match 21 | let noteIndex = notes.indexOf(note + (sharp === "#" ? "#" : "")) 22 | if (sharp === "b") { 23 | noteIndex-- 24 | } 25 | if (noteIndex >= 0) { 26 | return noteIndex + parseInt(octave) * 12 + 104 27 | } else { 28 | throw new Error(`Invalid note name: ${noteName}`) 29 | } 30 | } 31 | 32 | export interface Props { 33 | pitch: number 34 | onChange(pitch: number): void 35 | } 36 | 37 | export default function NoteInput({ pitch, onChange }: Props) { 38 | const ref = useRef(null) 39 | const [value, setValue] = useState(pitchToNoteName(pitch)) 40 | 41 | useEffect(() => { 42 | setValue(pitchToNoteName(pitch)) 43 | }, [pitch]) 44 | 45 | useEffect(() => { 46 | if (ref.current) 47 | ref.current.style.width = `${value.toString().length + 1}ch` 48 | }, [ref, value]) 49 | 50 | return setValue(evt.target.value)} 56 | onBlur={evt => { 57 | try { 58 | const newPitch = noteNameToPitch(evt.target.value) 59 | if (newPitch >= 0 && newPitch <= 255) { 60 | onChange(newPitch) 61 | } else { 62 | setValue(pitchToNoteName(pitch)) 63 | } 64 | } catch (err) { 65 | console.error(err) 66 | setValue(pitchToNoteName(pitch)) 67 | } 68 | }} 69 | /> 70 | } 71 | -------------------------------------------------------------------------------- /mamar-web/src/app/header/OpenButton.tsx: -------------------------------------------------------------------------------- 1 | import { ActionButton, AlertDialog, DialogContainer } from "@adobe/react-spectrum" 2 | import { fileOpen } from "browser-fs-access" 3 | import { useEffect, useState } from "react" 4 | 5 | import { useRoot } from "../store" 6 | import { openFile, RootAction } from "../store/root" 7 | 8 | export default function OpenButton() { 9 | const [, dispatch] = useRoot() 10 | const [loadError, setLoadError] = useState(null) 11 | 12 | useEffect(() => { 13 | // @ts-ignore 14 | if ("launchQueue" in window && "files" in LaunchParams.prototype) { 15 | 16 | // @ts-ignore 17 | launchQueue.setConsumer(async launchParams => { 18 | const actions: RootAction[] = [] 19 | 20 | for (const handle of launchParams.files) { 21 | const file = await handle.getFile() 22 | const action = await openFile(file) 23 | actions.push(action) 24 | } 25 | 26 | dispatch(...actions) 27 | }) 28 | } 29 | }, [dispatch]) 30 | 31 | return <> 32 | { 34 | const file = await fileOpen({ 35 | extensions: [".bgm", ".mid", ".midi", ".rmi", ".bin", ".ron"], 36 | description: "BGM and MIDI files", 37 | id: "bgm_open", 38 | }) 39 | 40 | try { 41 | const action = await openFile(file) 42 | dispatch(action) 43 | } catch (error) { 44 | console.error(error) 45 | if (error instanceof Error) { 46 | setLoadError(error) 47 | } 48 | } 49 | }} 50 | isQuiet 51 | >Open 52 | 53 | setLoadError(null)}> 54 | {loadError && 59 | Failed to decode the BGM. 60 | {loadError.message} 61 | } 62 | 63 | > 64 | } 65 | -------------------------------------------------------------------------------- /mamar-web/src/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Paper Mario music editor - Mamar 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | Mamar is loading... 33 | 34 | 42 | 43 | 44 | Please enable JavaScript to use Mamar. 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /mamar-web/src/app/VerticalDragNumberInput.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react" 2 | 3 | import styles from "./VerticalDragNumberInput.module.scss" 4 | 5 | export interface Props { 6 | value: number 7 | minValue: number 8 | maxValue: number 9 | onChange: (value: number) => void 10 | id?: string 11 | } 12 | 13 | export default function VerticalDragNumberInput({ value, minValue, maxValue, onChange, id }: Props) { 14 | const [snapshot, setSnapshot] = useState(value) 15 | const [startVal, setStartVal] = useState(0) 16 | 17 | const ref = useRef(null) 18 | 19 | const valueRef = useRef(value) 20 | valueRef.current = value 21 | 22 | useEffect(() => { 23 | const onUpdate = (evt: MouseEvent) => { 24 | if (startVal) { 25 | const delta = Math.floor((evt.clientY - startVal) / 25) * -1 26 | 27 | const newValue = snapshot + delta 28 | if (newValue >= minValue && newValue <= maxValue && newValue !== valueRef.current) { 29 | onChange(newValue) 30 | } 31 | } 32 | } 33 | 34 | const onEnd = () => { 35 | setStartVal(0) 36 | } 37 | 38 | document.addEventListener("mousemove", onUpdate) 39 | document.addEventListener("mouseup", onEnd) 40 | return () => { 41 | document.removeEventListener("mousemove", onUpdate) 42 | document.removeEventListener("mouseup", onEnd) 43 | } 44 | }, [startVal, onChange, snapshot, minValue, maxValue]) 45 | 46 | useEffect(() => { 47 | if (ref.current) 48 | ref.current.style.width = `${value.toString().length + 1}ch` 49 | }, [ref, value]) 50 | 51 | return { 60 | const value = parseInt(evt.target.value) 61 | if (value >= minValue && value <= maxValue) { 62 | onChange(value) 63 | } 64 | }} 65 | onMouseDown={evt => { 66 | setStartVal(evt.clientY) 67 | setSnapshot(value) 68 | 69 | evt.preventDefault() 70 | evt.stopPropagation() 71 | }} 72 | /> 73 | } 74 | -------------------------------------------------------------------------------- /mamar-web/src/app/doc/SegmentMap.module.scss: -------------------------------------------------------------------------------- 1 | $trackHeight: 70px; 2 | $trackHead-width: 100px; // Match with TRACK_HEAD_WIDTH 3 | 4 | .table { 5 | width: 100%; 6 | 7 | user-select: none; 8 | 9 | position: relative; /* for Playhead */ 10 | 11 | --ruler-zoom: 2; 12 | --trackHead-width: $trackHead-width; 13 | } 14 | 15 | .track { 16 | display: flex; 17 | width: max-content; 18 | height: $trackHeight; 19 | 20 | &:nth-child(even) { 21 | background: var(--spectrum-global-color-gray-75); 22 | } 23 | } 24 | 25 | .trackHead { 26 | width: $trackHead-width; 27 | border-right: 1px solid var(--spectrum-global-color-gray-100); 28 | 29 | padding: 4px 16px; 30 | height: 100%; 31 | 32 | position: sticky; 33 | left: 0; 34 | 35 | background: var(--spectrum-global-color-gray-100); 36 | 37 | .trackName { 38 | color: var(--spectrum-global-color-gray-900); 39 | margin-bottom: 4px; 40 | } 41 | } 42 | 43 | .pianoRollThumbnail { 44 | --color-800: #ffffffaa; 45 | --color-500: var(--spectrum-global-color-blue-500); 46 | --color-400: var(--spectrum-global-color-blue-400); 47 | --color-200: #000000cc; 48 | 49 | border: 1px solid var(--spectrum-global-color-gray-300); 50 | background: linear-gradient(var(--color-500) 0%, var(--color-400) 100%); 51 | color: #ffffffee; 52 | border-radius: 6px; 53 | overflow: hidden; 54 | 55 | height: $trackHeight; 56 | 57 | &:focus-visible { 58 | outline: 3px solid var(--spectrum-global-color-yellow-500); 59 | } 60 | 61 | .segmentName { 62 | padding: 6px 10px; 63 | } 64 | 65 | &.selected { 66 | border-color: var(--color-800); 67 | 68 | .segmentName { 69 | color: var(--color-200); 70 | background: var(--color-800); 71 | } 72 | } 73 | 74 | &.hasInterestingParentTrack { 75 | --color-500: var(--spectrum-global-color-indigo-500); 76 | --color-400: var(--spectrum-global-color-indigo-400); 77 | } 78 | 79 | &.drumRegion { 80 | --color-500: var(--spectrum-global-color-seafoam-500); 81 | --color-400: var(--spectrum-global-color-seafoam-400); 82 | } 83 | 84 | &.disabledRegion { 85 | --color-800: var(--spectrum-global-color-gray-800); 86 | --color-500: var(--spectrum-global-color-gray-500); 87 | --color-400: var(--spectrum-global-color-gray-400); 88 | --color-200: var(--spectrum-global-color-gray-200); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /mamar-web/src/app/DocTabs.scss: -------------------------------------------------------------------------------- 1 | .DocTabs_container { 2 | padding: 0 8px; 3 | 4 | .window-controls-overlay & { 5 | margin-top: 24px; 6 | } 7 | } 8 | 9 | .DocTabs_main_content { 10 | border-top: 1px solid var(--grey-2); 11 | overflow: hidden; 12 | } 13 | 14 | .DocTab { 15 | margin: 0; 16 | padding: 0 9px 0 12px; 17 | 18 | width: 160px; 19 | overflow: hidden; 20 | 21 | display: flex; 22 | flex-direction: row; 23 | align-items: center; 24 | 25 | color: var(--spectrum-global-color-gray-600); 26 | background: transparent; 27 | border: 0; 28 | border-bottom: 2px solid transparent; 29 | 30 | user-select: none; 31 | 32 | &:not(:last-child) { 33 | border-right: 1px solid var(--spectrum-global-color-gray-100); 34 | } 35 | 36 | &.active-true { 37 | color: var(--spectrum-global-color-gray-900); 38 | //background: var(--grey-0-alpha-50); 39 | border-bottom-color: var(--spectrum-global-color-gray-900); 40 | //backdrop-filter: blur(4px); 41 | } 42 | 43 | > span { 44 | flex: 1; 45 | overflow: hidden; 46 | text-overflow: ellipsis; 47 | white-space: nowrap; 48 | text-align: left; 49 | } 50 | 51 | .DocTab_Close { 52 | margin: 0; 53 | padding: 0; 54 | 55 | width: 18px; 56 | height: 18px; 57 | 58 | color: var(--spectrum-global-color-gray-600); 59 | background: transparent; 60 | border: 0; 61 | 62 | display: flex; 63 | align-items: center; 64 | justify-content: center; 65 | 66 | border-radius: 4px; 67 | 68 | cursor: pointer; 69 | 70 | &:hover { 71 | color: var(--spectrum-global-color-gray-900); 72 | background: var(--spectrum-global-color-gray-200); 73 | 74 | .DocTab_Close_UnsavedIcon { 75 | display: none; 76 | } 77 | } 78 | 79 | &:not(:hover):has(.DocTab_Close_UnsavedIcon) { 80 | .DocTab_Close_CloseIcon { 81 | display: none; 82 | } 83 | } 84 | 85 | .DocTab_Close_CloseIcon { 86 | width: 14px; 87 | height: 14px; 88 | opacity: 0; 89 | } 90 | 91 | .DocTab_Close_UnsavedIcon { 92 | width: 12px; 93 | height: 12px; 94 | color: var(--spectrum-global-color-gray-800); 95 | } 96 | } 97 | 98 | &:hover, 99 | &.active-true { 100 | .DocTab_Close_CloseIcon { 101 | opacity: 1; 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /mamar-web/src/app/doc/Ruler.module.scss: -------------------------------------------------------------------------------- 1 | $ruler-height: 36px; 2 | $trackHead-width: 100px; 3 | 4 | .ruler { 5 | display: flex; 6 | flex-direction: column; 7 | 8 | width: max-content; 9 | height: $ruler-height; 10 | 11 | margin-left: $trackHead-width; 12 | 13 | --ticks-per-beat: 48px; 14 | 15 | background-color: var(--spectrum-global-color-gray-200); 16 | 17 | position: sticky; 18 | top: 0; 19 | z-index: 1; /* above trackheads */ 20 | 21 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 22 | 23 | /* Hide ruler above where trackHeads are when scrolling */ 24 | &::after { 25 | content: ""; 26 | position: fixed; 27 | left: 0; 28 | width: $trackHead-width; 29 | height: $ruler-height; 30 | background: var(--spectrum-global-color-gray-100); 31 | } 32 | } 33 | 34 | .rulerSegment { 35 | display: flex; 36 | color: var(--spectrum-global-color-gray-800); 37 | 38 | cursor: pointer; 39 | 40 | border-left: 1px dashed var(--spectrum-global-color-gray-400); 41 | } 42 | 43 | .loops { 44 | display: flex; 45 | height: $ruler-height - 22px; 46 | } 47 | 48 | .loop { 49 | background-color: var(--yellow); 50 | 51 | border-radius: 6px; 52 | 53 | &.highlighted { 54 | filter: brightness(1.2); 55 | } 56 | 57 | width: 100%; 58 | 59 | display: flex; 60 | align-items: center; 61 | justify-content: end; 62 | } 63 | 64 | .loopIterCount { 65 | color: var(--spectrum-global-color-gray-200); 66 | margin-right: 4px; 67 | font-weight: 600; 68 | font-size: 0.9em; 69 | } 70 | 71 | .relative { 72 | position: relative; 73 | } 74 | 75 | .loopHandle { 76 | position: absolute; 77 | width: 16px; 78 | transform: translateX(-50%); 79 | height: 100%; 80 | 81 | pointer-events: auto; 82 | 83 | z-index: 1; 84 | 85 | &[data-kind=start] { 86 | cursor: w-resize; 87 | } 88 | 89 | &[data-kind=end] { 90 | cursor: e-resize; 91 | } 92 | 93 | &.active { 94 | position: fixed; 95 | inset: 0; 96 | width: 100%; 97 | height: 100%; 98 | transform: none; 99 | z-index: 9999; 100 | } 101 | } 102 | 103 | .bars { 104 | display: flex; 105 | 106 | --color: var(--spectrum-global-color-gray-400); 107 | background-image: repeating-linear-gradient(to right, var(--color) 0, var(--color) 1px, transparent 1px, transparent calc(var(--ticks-per-beat) / var(--ruler-zoom))); 108 | background-size: calc(var(--ticks-per-beat) / var(--ruler-zoom)) 50%; 109 | background-repeat: repeat no-repeat; 110 | background-position-y: calc(var(--ticks-per-beat) / 4); 111 | 112 | color: var(--spectrum-global-color-gray-700); 113 | } 114 | 115 | .bar { 116 | padding-left: calc(8px / var(--ruler-zoom)); 117 | border-left: var(--color) 1px solid; /* Extend the background-image */ 118 | flex: none; 119 | } 120 | -------------------------------------------------------------------------------- /mamar-web/src/app/App.tsx: -------------------------------------------------------------------------------- 1 | import { Provider as SpectrumProvider, defaultTheme, Grid, View } from "@adobe/react-spectrum" 2 | import { useEffect } from "react" 3 | 4 | import styles from "./App.module.scss" 5 | import PlaybackControls from "./emu/PlaybackControls" 6 | import Header from "./header/Header" 7 | import Main from "./Main" 8 | import { RootProvider } from "./store/dispatch" 9 | import { MupenProvider } from "./util/hooks/useMupen" 10 | import useRomData, { RomDataProvider } from "./util/hooks/useRomData" 11 | 12 | import { version } from "../../package.json" 13 | 14 | export function RomDataConsumer() { 15 | const romData = useRomData() 16 | 17 | return 18 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | } 39 | 40 | export default function App() { 41 | useEffect(() => { 42 | localStorage.MAMAR_VERSION = version 43 | }, []) 44 | 45 | useEffect(() => { 46 | const query = () => window.matchMedia("(prefers-color-scheme: light)") 47 | const update = () => { 48 | document.querySelector("meta[name=theme-color]")!.setAttribute("content", query().matches ? "#ffffff" : "#111111") 49 | } 50 | 51 | update() 52 | 53 | const q = query() 54 | q.addEventListener("change", update) 55 | return () => q.removeEventListener("change", update) 56 | }, []) 57 | 58 | useEffect(() => { 59 | if ("windowControlsOverlay" in navigator) { 60 | // @ts-ignore 61 | const { windowControlsOverlay } = navigator 62 | 63 | const update = () => { 64 | const { width } = windowControlsOverlay.getTitlebarAreaRect() 65 | 66 | if (width > 0) { 67 | document.body.classList.add("window-controls-overlay") 68 | } else { 69 | document.body.classList.remove("window-controls-overlay") 70 | } 71 | } 72 | 73 | update() 74 | 75 | windowControlsOverlay.addEventListener("geometrychange", update) 76 | return () => windowControlsOverlay.removeEventListener("geometrychange", update) 77 | } 78 | }, []) 79 | 80 | return 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | } 90 | -------------------------------------------------------------------------------- /pm64/src/bgm/mamar.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use log::debug; 4 | use serde_derive::{Deserialize, Serialize}; 5 | 6 | pub const MAGIC: &str = "MAMAR"; 7 | pub const MAGIC_MAX_LEN: usize = 8; 8 | 9 | /// Written in MessagePack after the "end" of a BGM, preceded by a "MAMAR" magic string. 10 | #[derive(Clone, Default, Debug, PartialEq, Eq, Deserialize, Serialize)] 11 | #[serde(default)] 12 | pub struct Metadata { 13 | /// Maps track list pos to vec of track names, excluding the master track. 14 | track_names: HashMap>, 15 | } 16 | 17 | impl Metadata { 18 | pub fn has_data(&self) -> bool { 19 | // Look for any non-empty track name 20 | self.track_names 21 | .values() 22 | .any(|names| names.iter().any(|name| !name.is_empty())) 23 | } 24 | 25 | pub fn apply_to_bgm(&self, bgm: &mut super::Bgm) { 26 | debug!("{:?}", self.track_names); 27 | for (id, track_list) in &mut bgm.track_lists { 28 | let Some(names) = self.track_names.get(&(*id as u16)) else { 29 | debug!("no names for {id:X}"); 30 | continue; 31 | }; 32 | 33 | for (track, name) in track_list.tracks.iter_mut().skip(1).zip(names.iter()) { 34 | track.name = name.clone(); 35 | } 36 | } 37 | } 38 | 39 | pub fn add_track_name(&mut self, tracks_pos: u16, name: String) { 40 | let names = self.track_names.entry(tracks_pos).or_default(); 41 | names.push(name); 42 | } 43 | } 44 | 45 | #[cfg(test)] 46 | mod test { 47 | use super::*; 48 | use crate::bgm::{Bgm, Segment, Track, TrackList, Variation}; 49 | 50 | #[test] 51 | fn encode_decode_metadata_preserves_track_names() { 52 | use std::collections::HashMap; 53 | 54 | let track_list = TrackList { 55 | pos: Some(0x1234), 56 | tracks: core::array::from_fn(|_| Track::default()), 57 | }; 58 | let mut bgm = Bgm { 59 | track_lists: HashMap::from([(0x1234, track_list)]), 60 | variations: [ 61 | Some(Variation { 62 | segments: vec![Segment::Subseg { 63 | id: 0, 64 | track_list: 0x1234, 65 | }], 66 | }), 67 | Default::default(), 68 | Default::default(), 69 | Default::default(), 70 | ], 71 | ..Default::default() 72 | }; 73 | 74 | let mut metadata = Metadata::default(); 75 | metadata.add_track_name(0x1234, "My Cool Track".to_string()); 76 | 77 | metadata.apply_to_bgm(&mut bgm); 78 | assert_eq!(bgm.track_lists[&0x1234].tracks[1].name, "My Cool Track"); 79 | 80 | let data = bgm.as_bytes().unwrap(); 81 | 82 | let bgm2 = Bgm::from_bytes(&data).unwrap(); 83 | assert_eq!(bgm2.track_lists.len(), 1); 84 | for (_, track_list) in bgm2.track_lists { 85 | assert_eq!(track_list.tracks[1].name, "My Cool Track"); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "react-app" 4 | ], 5 | "plugins": [ 6 | "@typescript-eslint", 7 | "import" 8 | ], 9 | "rules": { 10 | "semi": ["error", "never", { "beforeStatementContinuationChars": "always" }], 11 | "indent": ["error", 4], 12 | "quotes": ["error", "double"], 13 | "quote-props": ["error", "consistent"], 14 | "brace-style": "off", 15 | "@typescript-eslint/brace-style": ["error"], 16 | "object-curly-spacing": ["error", "always"], 17 | "array-bracket-spacing": ["error", "never"], 18 | "no-else-return": "off", 19 | "no-trailing-spaces": "error", 20 | "no-multi-spaces": "error", 21 | "no-multiple-empty-lines": ["error", { "max": 1, "maxBOF": 0, "maxEOF": 0 }], 22 | "comma-dangle": "off", 23 | "@typescript-eslint/comma-dangle": ["error", "always-multiline"], 24 | "comma-spacing": "off", 25 | "@typescript-eslint/comma-spacing": ["error"], 26 | "prefer-const": ["warn", { "destructuring": "all" }], 27 | "arrow-parens": ["error", "as-needed"], 28 | "no-confusing-arrow": ["error", { "allowParens": true }], 29 | "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], 30 | "no-extra-semi": "off", 31 | "@typescript-eslint/no-explicit-any": "off", 32 | "@typescript-eslint/no-extra-semi": ["error"], 33 | "no-empty-function": "off", 34 | "@typescript-eslint/no-empty-function": "off", 35 | "no-unused-expressions": "off", 36 | "@typescript-eslint/no-unused-expressions": ["warn"], 37 | "@typescript-eslint/ban-ts-comment": "off", 38 | "keyword-spacing": "off", 39 | "@typescript-eslint/keyword-spacing": ["error"], 40 | "@typescript-eslint/member-delimiter-style": ["error", { 41 | "multiline": { 42 | "delimiter": "none", 43 | "requireLast": true 44 | }, 45 | "singleline": { 46 | "delimiter": "comma", 47 | "requireLast": false 48 | }, 49 | "multilineDetection": "brackets" 50 | }], 51 | 52 | "import/newline-after-import": "error", 53 | "import/no-duplicates": "error", 54 | "import/order": [ 55 | "error", 56 | { 57 | "groups": ["builtin", "external", "internal", "sibling", "index", "object"], 58 | "newlines-between": "always", 59 | "pathGroups": [ 60 | { 61 | "pattern": "react|react-dom", 62 | "group": "builtin", 63 | "position": "before" 64 | } 65 | ], 66 | "pathGroupsExcludedImportTypes": ["react"], 67 | "alphabetize": { "order": "asc", "caseInsensitive": true } 68 | } 69 | ] 70 | }, 71 | "overrides": [ 72 | { 73 | "files": ["service-worker.js"], 74 | "rules": { 75 | "no-restricted-globals": "off" 76 | }, 77 | "env": { 78 | "serviceworker": true 79 | } 80 | } 81 | ] 82 | } 83 | -------------------------------------------------------------------------------- /mamar-wasm-bridge/src/lib.rs: -------------------------------------------------------------------------------- 1 | use pm64::bgm::*; 2 | use pm64::sbn::Sbn; 3 | use serde::{Deserialize, Serialize}; 4 | use std::io::Cursor; 5 | use wasm_bindgen::prelude::*; 6 | 7 | fn to_js Deserialize<'a>>(t: &T) -> JsValue { 8 | #[allow(deprecated)] 9 | JsValue::from_serde(t).unwrap() 10 | } 11 | 12 | fn from_js Deserialize<'a>>(value: &JsValue) -> T { 13 | #[allow(deprecated)] 14 | JsValue::into_serde(value).unwrap() 15 | } 16 | 17 | #[wasm_bindgen] 18 | pub fn init_logging() { 19 | console_error_panic_hook::set_once(); 20 | console_log::init_with_level(log::Level::Debug).unwrap(); 21 | } 22 | 23 | #[wasm_bindgen] 24 | pub fn new_bgm() -> JsValue { 25 | let bgm = Bgm::new(); 26 | to_js(&bgm) 27 | } 28 | 29 | #[wasm_bindgen] 30 | pub fn bgm_decode(data: &[u8]) -> JsValue { 31 | let mut f = Cursor::new(data); 32 | 33 | if pm64::bgm::midi::is_midi(&mut f).unwrap_or(false) { 34 | match pm64::bgm::midi::to_bgm(data) { 35 | Ok(bgm) => to_js(&bgm), 36 | Err(e) => to_js(&e.to_string()), 37 | } 38 | } else if data[0] == b'B' && data[1] == b'G' && data[2] == b'M' && data[3] == b' ' { 39 | match Bgm::decode(&mut f) { 40 | Ok(bgm) => to_js(&bgm), 41 | Err(e) => to_js(&e.to_string()), 42 | } 43 | } else { 44 | match ron::from_str::(&String::from_utf8_lossy(data)) { 45 | Ok(bgm) => to_js(&bgm), 46 | Err(e) => to_js(&e.to_string()), 47 | } 48 | } 49 | } 50 | 51 | #[wasm_bindgen] 52 | pub fn bgm_encode(bgm: &JsValue) -> JsValue { 53 | let bgm: Bgm = from_js(bgm); 54 | let mut f = Cursor::new(Vec::new()); 55 | match bgm.encode(&mut f) { 56 | Ok(_) => { 57 | let data: Vec = f.into_inner(); 58 | let arr = js_sys::Uint8Array::new_with_length(data.len() as u32); 59 | for (i, v) in data.into_iter().enumerate() { 60 | arr.set_index(i as u32, v); 61 | } 62 | arr.into() 63 | } 64 | Err(e) => e.to_string().into(), 65 | } 66 | } 67 | 68 | #[wasm_bindgen] 69 | pub fn ron_encode(bgm: &JsValue) -> JsValue { 70 | let bgm: Bgm = from_js(bgm); 71 | match ron::ser::to_string_pretty(&bgm, ron::ser::PrettyConfig::new().indentor(" ").depth_limit(5)) { 72 | Ok(ron) => ron.to_string().into(), 73 | Err(e) => e.to_string().into(), 74 | } 75 | } 76 | 77 | #[wasm_bindgen] 78 | pub fn sbn_decode(rom: &[u8]) -> JsValue { 79 | const SBN_START: usize = 0xF00000; 80 | const SBN_END: usize = SBN_START + 0xA42C40; 81 | 82 | let mut f = Cursor::new(&rom[SBN_START..SBN_END]); 83 | match Sbn::decode(&mut f) { 84 | Ok(sbn) => to_js(&sbn), 85 | Err(e) => to_js(&e.to_string()), 86 | } 87 | } 88 | 89 | #[wasm_bindgen] 90 | pub fn bgm_add_voice(bgm: &JsValue) -> JsValue { 91 | let mut bgm: Bgm = from_js(bgm); 92 | log::info!("bgm_add_voice {:?}", bgm); 93 | bgm.instruments.push(Instrument::default()); 94 | to_js(&bgm) 95 | } 96 | 97 | #[wasm_bindgen] 98 | pub fn bgm_split_variation_at(bgm: &JsValue, variation: usize, time: usize) -> JsValue { 99 | let mut bgm: Bgm = from_js(bgm); 100 | bgm.split_variation_at(variation, time); 101 | to_js(&bgm) 102 | } 103 | -------------------------------------------------------------------------------- /mamar-web/src/app/store/root.ts: -------------------------------------------------------------------------------- 1 | import { FileWithHandle } from "browser-fs-access" 2 | import { bgm_decode, new_bgm } from "mamar-wasm-bridge" 3 | import { Bgm } from "pm64-typegen" 4 | 5 | import { Doc, DocAction, docReducer } from "./doc" 6 | 7 | function generateId() { 8 | return Math.random().toString(36).substring(2, 15) 9 | } 10 | 11 | export interface Root { 12 | docs: { [id: string]: Doc } 13 | activeDocId?: string 14 | } 15 | 16 | export type RootAction = { 17 | type: "doc" 18 | id: string 19 | action: DocAction 20 | } | { 21 | type: "focus_doc" 22 | id: string 23 | } | { 24 | type: "open_doc" 25 | file?: FileWithHandle 26 | name?: string 27 | bgm?: Bgm 28 | } | { 29 | type: "close_doc" 30 | id: string 31 | } 32 | 33 | export function rootReducer(root: Root, action: RootAction): Root { 34 | switch (action.type) { 35 | case "doc": 36 | return { 37 | ...root, 38 | docs: { 39 | ...root.docs, 40 | [action.id]: docReducer(root.docs[action.id], action.action), 41 | }, 42 | } 43 | case "focus_doc": 44 | return { 45 | ...root, 46 | activeDocId: action.id, 47 | } 48 | case "open_doc": { 49 | const fileExtension = action.file?.name?.split(".").pop()?.toLowerCase() 50 | const saveSupported = fileExtension === "bgm" || fileExtension === "ron" 51 | const newDoc: Doc = { 52 | id: generateId(), 53 | bgm: action.bgm ?? new_bgm(), 54 | fileHandle: saveSupported ? action.file?.handle : undefined, 55 | name: action.name || action.file?.name || "New song", 56 | isSaved: saveSupported, 57 | activeVariation: 0, 58 | panelContent: { 59 | type: "not_open", 60 | }, 61 | } 62 | return { 63 | ...root, 64 | docs: { 65 | ...root.docs, 66 | [newDoc.id]: newDoc, 67 | }, 68 | activeDocId: newDoc.id, 69 | } 70 | } case "close_doc": { 71 | const newDocs = Object.assign({}, root.docs) 72 | delete newDocs[action.id] 73 | 74 | const docValues = Object.values(newDocs) 75 | const lastDoc = docValues.length > 0 ? docValues[docValues.length - 1] : undefined 76 | 77 | return { 78 | ...root, 79 | docs: newDocs, 80 | activeDocId: root.activeDocId === action.id ? lastDoc?.id : root.activeDocId, 81 | } 82 | } 83 | } 84 | } 85 | 86 | export async function openFile(file: FileWithHandle): Promise { 87 | const data = new Uint8Array(await file.arrayBuffer()) 88 | const bgm: Bgm | string = bgm_decode(data) 89 | 90 | if (typeof bgm === "string") { 91 | throw new Error(bgm) 92 | } 93 | 94 | return { 95 | type: "open_doc", 96 | file, 97 | bgm, 98 | } 99 | } 100 | 101 | export function openData(data: Uint8Array, name?: string): RootAction { 102 | const bgm: Bgm | string = bgm_decode(data) 103 | 104 | if (typeof bgm === "string") { 105 | throw new Error(bgm) 106 | } 107 | 108 | return { 109 | type: "open_doc", 110 | name, 111 | bgm, 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /mamar-web/src/app/icon/loadingspinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /mamar-web/src/app/header/BgmActionGroup.tsx: -------------------------------------------------------------------------------- 1 | import { View, ActionButton, Tooltip, TooltipTrigger } from "@adobe/react-spectrum" 2 | import { fileSave } from "browser-fs-access" 3 | import { bgm_encode, ron_encode } from "mamar-wasm-bridge" 4 | import { CSSProperties, useCallback, useEffect } from "react" 5 | 6 | import OpenButton from "./OpenButton" 7 | 8 | import { useDoc, useRoot } from "../store" 9 | 10 | function createBgmFileName(fileName: string) { 11 | // Remove supported extension 12 | let extension = "" 13 | if (fileName.endsWith(".bgm") || fileName.endsWith(".ron") || fileName.endsWith(".mid")) { 14 | const index = fileName.lastIndexOf(".") 15 | if (index !== -1) { 16 | fileName = fileName.substring(0, index) 17 | extension = fileName.substring(index + 1) 18 | } 19 | } 20 | fileName += extension === ".ron" ? ".ron" : ".bgm" 21 | return fileName 22 | } 23 | 24 | export default function BgmActionGroup() { 25 | const [, dispatch] = useRoot() 26 | const [doc, docDispatch] = useDoc() 27 | 28 | JSON.stringify(doc?.bgm) 29 | 30 | const save = useCallback(async (saveAs: boolean) => { 31 | if (!doc) { 32 | return 33 | } 34 | 35 | const bgmBin: Uint8Array | string = bgm_encode(doc.bgm) 36 | 37 | if (typeof bgmBin === "string") { 38 | // TODO: surface error in a dialog 39 | throw new Error(bgmBin) 40 | } 41 | 42 | const fileHandle = await fileSave(new Blob([bgmBin]), { 43 | fileName: createBgmFileName(doc.name), 44 | extensions: [".bgm", ".ron"], 45 | startIn: "music", 46 | }, saveAs ? undefined : doc.fileHandle) 47 | 48 | // If it was saved as .ron, overwrite the file contents (currently BGM) with the RON 49 | if (fileHandle?.name.endsWith(".ron")) { 50 | const writable = await fileHandle.createWritable({ keepExistingData: false }) 51 | await writable.write(ron_encode(doc.bgm)) 52 | await writable.close() 53 | } 54 | 55 | docDispatch({ type: "mark_saved", fileHandle }) 56 | }, [doc, docDispatch]) 57 | 58 | useEffect(() => { 59 | const handleKeyDown = (evt: KeyboardEvent) => { 60 | if (evt.ctrlKey && evt.key === "s") { 61 | evt.preventDefault() 62 | save(evt.shiftKey) 63 | } 64 | } 65 | window.addEventListener("keydown", handleKeyDown) 66 | return () => window.removeEventListener("keydown", handleKeyDown) 67 | }, [save]) 68 | 69 | const props = { 70 | isQuiet: true, 71 | } 72 | 73 | return 78 | dispatch({ type: "open_doc" })} 80 | {...props} 81 | >New 82 | 83 | 84 | save(evt.shiftKey)} 86 | isDisabled={!doc} 87 | {...props} 88 | > 89 | Save 90 | 91 | Hold Shift to Save As 92 | 93 | dispatch.undo()} 95 | isDisabled={!dispatch.canUndo} 96 | {...props} 97 | >Undo 98 | dispatch.redo()} 100 | isDisabled={!dispatch.canRedo} 101 | {...props} 102 | >Redo 103 | 104 | } 105 | -------------------------------------------------------------------------------- /mamar-web/src/app/sbn/BgmFromSbnPicker.tsx: -------------------------------------------------------------------------------- 1 | import { TableView, TableHeader, Column, Row, TableBody, Cell, View, DialogContainer, AlertDialog, Link } from "@adobe/react-spectrum" 2 | import { Sbn, File, Song } from "pm64-typegen" 3 | import { useMemo, useState } from "react" 4 | 5 | import { names } from "./songNames.json" 6 | import useDecodedSbn from "./useDecodedSbn" 7 | 8 | import { useRoot } from "../store" 9 | import { openData } from "../store/root" 10 | import useRomData from "../util/hooks/useRomData" 11 | 12 | import "./BgmFromSbnPicker.scss" 13 | 14 | interface Item { 15 | id: number 16 | name: string 17 | file: File 18 | song: Song 19 | } 20 | 21 | function getRows(sbn: Sbn | null): Item[] { 22 | const items: Item[] = [] 23 | 24 | if (sbn) { 25 | for (let i = 0; i < sbn.songs.length; i++) { 26 | const song = sbn.songs[i] 27 | 28 | items.push({ 29 | id: i, 30 | name: names[i] ?? "", 31 | song, 32 | file: sbn.files[song.bgm_file], 33 | }) 34 | } 35 | } 36 | 37 | return items 38 | } 39 | 40 | export default function BgmFromSbnPicker() { 41 | const [, dispatch] = useRoot() 42 | const [loadError, setLoadError] = useState(null) 43 | const romData = useRomData() 44 | const sbn = useDecodedSbn(romData) 45 | const items = useMemo(() => { 46 | return getRows(sbn) 47 | }, [sbn]) 48 | 49 | return 50 | { 55 | const item = items.find(item => item.id.toString() === key) 56 | 57 | if (item) { 58 | try { 59 | const action = openData(new Uint8Array(item.file.data), item.name) 60 | dispatch(action) 61 | } catch (error) { 62 | console.error(error) 63 | if (error instanceof Error) { 64 | setLoadError(error) 65 | } 66 | } 67 | } 68 | }} 69 | > 70 | 71 | ID 72 | Song 73 | Extra soundbanks 74 | Size 75 | 76 | 77 | {row => ( 78 | 79 | 80 | {row.id.toString(16).toUpperCase()} 81 | 82 | 83 | {row.name} 84 | 85 | 86 | {row.song.bk_a_file} {row.song.bk_b_file} {row.song.unk_file} 87 | 88 | 89 | {(row.file.data.length / 1024).toFixed(1)} KB 90 | 91 | 92 | )} 93 | 94 | 95 | setLoadError(null)}> 96 | {loadError && 101 | Failed to decode the BGM. 102 | If this is an unmodified ROM, please report this as a bug. 103 | {loadError.message} 104 | } 105 | 106 | 107 | } 108 | -------------------------------------------------------------------------------- /pm64/src/sbn/de.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryInto; 2 | use std::fmt; 3 | use std::io::prelude::*; 4 | use std::io::{self, SeekFrom}; 5 | 6 | use log::warn; 7 | 8 | use super::*; 9 | 10 | #[derive(Debug)] 11 | pub enum Error { 12 | InvalidMagic, 13 | Io(io::Error), 14 | } 15 | 16 | type Result = std::result::Result; 17 | 18 | impl From for Error { 19 | fn from(io: io::Error) -> Self { 20 | Self::Io(io) 21 | } 22 | } 23 | 24 | impl fmt::Display for Error { 25 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 26 | match self { 27 | Error::InvalidMagic => write!(f, "Missing 'SBN' signature at start"), 28 | Error::Io(source) => write!(f, "{}", source), 29 | } 30 | } 31 | } 32 | 33 | impl std::error::Error for Error { 34 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 35 | match self { 36 | Error::Io(source) => Some(source), 37 | _ => None, 38 | } 39 | } 40 | } 41 | 42 | impl Sbn { 43 | pub fn from_bytes(f: &[u8]) -> Result { 44 | Self::decode(&mut std::io::Cursor::new(f)) 45 | } 46 | 47 | pub fn decode(f: &mut R) -> Result { 48 | if f.read_cstring(4)? != MAGIC { 49 | return Err(Error::InvalidMagic); 50 | } 51 | 52 | debug_assert!(f.pos()? == 0x04); 53 | let internal_size = f.read_u32_be()?; 54 | let true_size = f.seek(SeekFrom::End(0))? as u32; 55 | if internal_size == true_size { 56 | // Ok 57 | } else if align(internal_size, 16) == true_size { 58 | // Make sure the trailing bytes are all zero 59 | f.seek(SeekFrom::Start(internal_size as u64))?; 60 | f.read_padding(true_size - internal_size)?; 61 | } else { 62 | warn!( 63 | "size mismatch! SBN says it is {:#X} B but the input is {:#X} B", 64 | internal_size, true_size 65 | ); 66 | } 67 | 68 | f.seek(SeekFrom::Start(0x08))?; 69 | f.read_padding(8)?; 70 | 71 | let files_start = f.read_u32_be()?; 72 | let num_files = f.read_u32_be()?; 73 | 74 | f.seek(SeekFrom::Current(12))?; // TODO: what is this data 75 | 76 | let songs_start = f.read_u32_be()?; 77 | 78 | let mut sbn = Self { 79 | files: Vec::with_capacity(num_files as usize), 80 | songs: Vec::new(), 81 | }; 82 | 83 | for i in 0..num_files { 84 | f.seek(SeekFrom::Start((files_start + i * 8) as u64))?; 85 | 86 | let file_start = f.read_u32_be()?; 87 | 88 | f.seek(SeekFrom::Start(file_start as u64))?; 89 | 90 | let _file_magic = f.read_cstring(4)?; 91 | let file_size = f.read_u32_be()?; 92 | 93 | sbn.files.push(File { 94 | name: f.read_cstring(4)?, 95 | data: { 96 | f.seek(SeekFrom::Start(file_start as u64))?; 97 | 98 | let mut bytes = vec![0; file_size as usize]; 99 | f.read_exact(&mut bytes)?; 100 | 101 | bytes 102 | }, 103 | }); 104 | } 105 | 106 | // Q: why +0x130? what is the data here? 107 | f.seek(SeekFrom::Start(songs_start as u64 + 0x130))?; 108 | 109 | loop { 110 | sbn.songs.push(Song { 111 | bgm_file: { 112 | let value = f.read_u16_be()?; 113 | if value == u16::MAX { 114 | break; 115 | } else { 116 | value 117 | } 118 | }, 119 | bk_a_file: f.read_u16_be()?.try_into().ok(), 120 | bk_b_file: f.read_u16_be()?.try_into().ok(), 121 | unk_file: f.read_u16_be()?.try_into().ok(), 122 | }); 123 | } 124 | 125 | Ok(sbn) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | ## 1.1.0 2 | 3 | - Fixed 'Save' button 4 | - You can also shift-click to 'Save As' 5 | - Added progressive web app support 6 | - Suppporting browsers will be able to install the site as an app 7 | - Mamar now works completely offline 8 | - Once Mamar is installed, BGM and MIDI files can be opened directly from the file system with the OS "Open with" dialog 9 | 10 | ## 1.0.0 11 | 12 | - Completely rewritten GUI 13 | - Moved to a web app rather than a desktop app 14 | - Added a landing page: https://mamar.nanaian.town 15 | - Added support for opening BGM files from an original game ROM without needing to extract them yourself 16 | - Added a tracker for editing commands in tracks 17 | - Made mute/solo feature realtime rather than require restarting the song 18 | - Added realtime tempo display 19 | - Added a selector to preview ambient sound effects (such as the monkey sounds used in Jade Jungle) alongside the song 20 | - Removed support for `.ron` files. Use 0.10.0 to convert them to `.bgm` and then 1.0.0 can open them. 21 | 22 | ## 0.10.0 23 | 24 | - Added tabs to switch variation 25 | - Added loop controls 26 | - Added 'New file' and 'Add section' buttons 27 | - Added buttons to reorder sections ('v' and '^' buttons) 28 | - When importing, MIDI tracks named "percussion" will be assumed to be drum tracks 29 | 30 | ## 0.9.0 31 | 32 | - Greatly improved MIDI importing 33 | - Added a new file open/save type: `.ron` files 34 | - These are structured, human-readable text files that Mamar can open and save 35 | - This feature is intended for manually doing things that the Mamar interface doesn't (yet) support 36 | - The file format is not guaranteed to stay stable between releases of Mamar; if the format changes, you will always be able to open the file in an older version of Mamar, save it as a `.bgm` file, then open the `.bgm` file in the latest release. Note however that this will discard some data such as the names of tracks. 37 | - Added Discord Rich Presence support ('Playing Mamar' status) 38 | 39 | ## 0.8.1 40 | 41 | - Reduced CPU use when connected to an emulator 42 | 43 | ## 0.8.0 44 | 45 | - Some instruments are named, press the _Set Instrument..._ button in the voice editor to view 46 | - The lower nibble of voice banks is now called 'staccato' (higher values have a shorter release time) and appears in the voice editor 47 | - MIDI pitch shift events are now translated to PM64 ones. The tuning will probably be off 48 | 49 | ## 0.7.0 50 | 51 | - Voice (instrument) editing. Click a track and press 'Edit voice'. 52 | - Much improved hot-reload server. It now tells you if an emulator is connected and lets you reconnect after a disconnection. 53 | - No changes to the Project64 script. 54 | - Segments are now called "Variations". 55 | - Subsegments are now called "Sections", and only those with tracks are shown in the UI. 56 | - Tracks, variations, and sections are now given names when importing from a MIDI or named by file offset when viewing a BGM. 57 | - Some track flags have been given names. 58 | 59 | ## 0.6.0 60 | 61 | - Added solo (S) and mute (M) toggles to tracks. 62 | - Muted tracks have their note velocities set to zero. 63 | - If any tracks are solo'd, only those that are solo'd will play. 64 | - Solo/mute state becomes permanent when you save the file; muting a track, saving the file, then reloading it will cause all the notes in that track to become irrecovably silent. 65 | - Added a track flag editor window. Click the track name in the list to view. 66 | - Various grapical improvements. 67 | 68 | ## 0.5.1 69 | 70 | ## 0.5.0 71 | 72 | - Added ability to view segment, subsegment, and track flags. You can also swap tracks between 'Voice' and 'Drum' mode. 73 | - Added _Reload File_ action (for @eldexterr) 74 | - Fixed _Save_ action 75 | 76 | ## 0.4.1 77 | 78 | ## 0.4.0 79 | 80 | - UI rewrite 81 | 82 | ## 0.3.0 83 | 84 | - MIDI files can now be opened and played 85 | - Added _Save As..._ button 86 | - Switched to Inter Medium font for better legibility (was Inter Regular) 87 | 88 | ## 0.2.0 89 | 90 | - Entirely new app architecture 91 | - You can mute/unmute instruments 92 | - Dropped support for web version 93 | 94 | ## 0.1.0 95 | 96 | Proof-of-concept release 97 | -------------------------------------------------------------------------------- /mamar-web/src/app/doc/ActiveDoc.tsx: -------------------------------------------------------------------------------- 1 | import { View } from "@adobe/react-spectrum" 2 | import { useEffect } from "react" 3 | import { DragDropContext, Droppable, DropResult } from "react-beautiful-dnd" 4 | 5 | import styles from "./ActiveDoc.module.scss" 6 | import SegmentMap from "./SegmentMap" 7 | import SubsegDetails from "./SubsegDetails" 8 | 9 | import { useDoc } from "../store" 10 | import WelcomeScreen from "../WelcomeScreen" 11 | 12 | export default function ActiveDoc() { 13 | const [doc, dispatch] = useDoc() 14 | 15 | const title = doc ? (doc.isSaved ? doc.name : `${doc.name} (unsaved)`) : "Mamar" 16 | useEffect(() => { 17 | document.title = title 18 | 19 | if (doc && !doc.isSaved) { 20 | const onbeforeunload = (evt: BeforeUnloadEvent) => { 21 | evt.preventDefault() 22 | return evt.returnValue = "You have unsaved changes." 23 | } 24 | window.addEventListener("beforeunload", onbeforeunload) 25 | return () => window.removeEventListener("beforeunload", onbeforeunload) 26 | } 27 | }, [title, doc]) 28 | 29 | const trackListId = doc?.panelContent.type === "tracker" ? doc?.panelContent.trackList : null 30 | const trackIndex = doc?.panelContent.type === "tracker" ? doc?.panelContent.track : null 31 | 32 | function onDragEnd(result: DropResult) { 33 | if (!trackListId || !trackIndex) { 34 | console.warn("drag end with no open region") 35 | return 36 | } 37 | 38 | if (!result.destination) { 39 | return 40 | } 41 | 42 | if (result.destination.droppableId === "trash") { 43 | dispatch({ 44 | type: "bgm", 45 | action: { 46 | type: "delete_track_command", 47 | trackList: trackListId, 48 | track: trackIndex, 49 | index: result.source.index, 50 | }, 51 | }) 52 | } else { 53 | dispatch({ 54 | type: "bgm", 55 | action: { 56 | type: "move_track_command", 57 | trackList: trackListId, 58 | track: trackIndex, 59 | oldIndex: result.source.index, 60 | newIndex: result.destination.index, 61 | }, 62 | }) 63 | } 64 | } 65 | 66 | if (!doc) { 67 | return 68 | } 69 | 70 | if (doc.activeVariation < 0) { 71 | return 72 | } else { 73 | return 74 | 75 | {(provided, _snapshot) => ( 76 | 85 | 86 | 87 | {provided.placeholder} 88 | 89 | {doc.panelContent.type !== "not_open" && 97 | {doc.panelContent.type === "tracker" && } 98 | } 99 | 100 | )} 101 | 102 | 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /mamar-web/src/app/store/bgm.ts: -------------------------------------------------------------------------------- 1 | import produce from "immer" 2 | import { bgm_add_voice, bgm_split_variation_at } from "mamar-wasm-bridge" 3 | import { Bgm, Event, Instrument } from "pm64-typegen" 4 | import { arrayMove } from "react-movable" 5 | 6 | import { useDoc } from "./doc" 7 | import { VariationAction, variationReducer } from "./variation" 8 | 9 | export type BgmAction = { 10 | type: "variation" 11 | index: number 12 | action: VariationAction 13 | } | { 14 | type: "add_voice" 15 | } | { 16 | type: "move_track_command" 17 | trackList: number 18 | track: number 19 | oldIndex: number 20 | newIndex: number 21 | } | { 22 | type: "update_track_command" 23 | trackList: number 24 | track: number 25 | command: Event 26 | } | { 27 | type: "delete_track_command" 28 | trackList: number 29 | track: number 30 | index: number 31 | } | { 32 | type: "modify_track_settings" 33 | trackList: number 34 | track: number 35 | name?: string 36 | isDisabled?: boolean 37 | polyphonicIdx?: number 38 | isDrumTrack?: boolean 39 | parentTrackIdx?: number 40 | } | { 41 | type: "update_instrument" 42 | index: number 43 | partial: Partial 44 | } | { 45 | type: "split_variation" 46 | variation: number 47 | time: number 48 | } 49 | 50 | export function bgmReducer(bgm: Bgm, action: BgmAction): Bgm { 51 | switch (action.type) { 52 | case "variation": { 53 | const applyVariation = (index: number) => { 54 | const variation = bgm.variations[index] 55 | if (index === action.index && variation) { 56 | return variationReducer(variation, action.action) 57 | } else { 58 | return variation 59 | } 60 | } 61 | return { 62 | ...bgm, 63 | variations: [ 64 | applyVariation(0), 65 | applyVariation(1), 66 | applyVariation(2), 67 | applyVariation(3), 68 | ], 69 | } 70 | } case "add_voice": 71 | return bgm_add_voice(bgm) 72 | case "move_track_command": 73 | return produce(bgm, draft => { 74 | const { commands } = draft.trackLists[action.trackList].tracks[action.track] 75 | commands.vec = arrayMove(commands.vec, action.oldIndex, action.newIndex) 76 | }) 77 | case "update_track_command": 78 | return produce(bgm, draft => { 79 | const { commands } = draft.trackLists[action.trackList].tracks[action.track] 80 | for (let i = 0; i < commands.vec.length; i++) { 81 | if (commands.vec[i].id === action.command.id) { 82 | commands.vec[i] = action.command 83 | } 84 | } 85 | }) 86 | case "delete_track_command": 87 | return produce(bgm, draft => { 88 | const { commands } = draft.trackLists[action.trackList].tracks[action.track] 89 | commands.vec.splice(action.index, 1) 90 | }) 91 | case "modify_track_settings": 92 | return produce(bgm, draft => { 93 | const track = draft.trackLists[action.trackList].tracks[action.track] 94 | if (action.name !== undefined) { 95 | track.name = action.name 96 | } 97 | if (action.isDisabled !== undefined) { 98 | track.isDisabled = action.isDisabled 99 | } 100 | if (action.polyphonicIdx !== undefined) { 101 | track.polyphonicIdx = action.polyphonicIdx 102 | } 103 | if (action.isDrumTrack !== undefined) { 104 | track.isDrumTrack = action.isDrumTrack 105 | } 106 | if (action.parentTrackIdx !== undefined) { 107 | track.parentTrackIdx = action.parentTrackIdx 108 | } 109 | }) 110 | case "update_instrument": 111 | return produce(bgm, draft => { 112 | const instrument = draft.instruments[action.index] 113 | Object.assign(instrument, action.partial) 114 | }) 115 | case "split_variation": 116 | return bgm_split_variation_at(bgm, action.variation, action.time) 117 | } 118 | } 119 | 120 | export const useBgm = (docId?: string): [Bgm | undefined, (action: BgmAction) => void] => { 121 | const [doc, dispatch] = useDoc(docId) 122 | return [doc?.bgm, action => dispatch({ type: "bgm", action })] 123 | } 124 | -------------------------------------------------------------------------------- /mamar-web/src/app/util/hooks/useMupen.tsx: -------------------------------------------------------------------------------- 1 | import createMupen64PlusWeb, { EmulatorControls } from "mupen64plus-web" 2 | import { useEffect, useRef, useState, useContext, createContext, ReactNode, MutableRefObject } from "react" 3 | 4 | import { loading } from "../.." 5 | 6 | enum State { 7 | MOUNTING, 8 | LOADING, 9 | STARTED, 10 | READY, 11 | RELOADING, 12 | } 13 | 14 | type ViFn = (emu: EmulatorControls) => void 15 | 16 | interface Context { 17 | emu: EmulatorControls 18 | viRef: MutableRefObject 19 | } 20 | 21 | const mupenCtx = createContext(null) 22 | 23 | export function MupenProvider({ romData, children }: { romData: ArrayBuffer, children: ReactNode }) { 24 | const [emu, setEmu] = useState() 25 | const [error, setError] = useState() 26 | const state = useRef(State.MOUNTING) 27 | const viRef = useRef([]) 28 | 29 | useEffect(() => { 30 | if (!romData) { 31 | emu?.pause?.() 32 | return 33 | } 34 | 35 | switch (state.current) { 36 | case State.MOUNTING: { 37 | // Load the emulator 38 | state.current = State.LOADING 39 | let module: any 40 | let emu: EmulatorControls 41 | createMupen64PlusWeb({ 42 | canvas: document.getElementById("canvas") as HTMLCanvasElement, 43 | romData, 44 | beginStats: () => {}, 45 | endStats: () => { 46 | for (const cb of viRef.current) { 47 | cb(emu) 48 | } 49 | }, 50 | coreConfig: { 51 | emuMode: 0, 52 | }, 53 | locateFile(path: string, prefix: string) { 54 | if (path.endsWith(".wasm") || path.endsWith(".data")) { 55 | return "/mupen64plus-web/" + path 56 | } 57 | 58 | return prefix + path 59 | }, 60 | setErrorStatus(errorMessage) { 61 | if (state.current === State.LOADING) { 62 | setError(errorMessage) 63 | } 64 | }, 65 | // @ts-ignore 66 | preRun: [m => { 67 | module = m 68 | }], 69 | }).then(async _emu => { 70 | emu = _emu 71 | if (emu) { 72 | await emu.start() 73 | state.current = State.STARTED 74 | setEmu(emu) 75 | module.JSEvents.removeAllEventListeners() 76 | } 77 | }).catch(error => { 78 | // XXX: mupen64plus-web never rejects, it just calls setErrorStatus 79 | setError(error) 80 | }) 81 | break 82 | } case State.STARTED: 83 | state.current = State.READY 84 | break 85 | case State.READY: 86 | if (!emu) 87 | break 88 | console.log("reloading ROM") 89 | state.current = State.RELOADING 90 | 91 | // Emulator must be running to reload rom 92 | emu.resume() 93 | .then(() => new Promise(r => setTimeout(r, 100))) 94 | .then(() => emu.reloadRom(romData)) 95 | .finally(() => { 96 | console.log("rom reload complete") 97 | state.current = State.READY 98 | }) 99 | break 100 | case State.RELOADING: 101 | console.warn("ignoring ROM reload, already reloading") 102 | break 103 | } 104 | }, [emu, romData]) 105 | 106 | if (error) { 107 | throw error 108 | } 109 | 110 | if (!emu) { 111 | return loading 112 | } 113 | 114 | return 115 | {children} 116 | 117 | } 118 | 119 | export default function useMupen(vi?: ViFn): Context { 120 | const ctx = useContext(mupenCtx) 121 | 122 | if (!ctx) { 123 | throw new Error("useMupen must be used within a MupenProvider") 124 | } 125 | 126 | useEffect(() => { 127 | if (!vi) 128 | return 129 | 130 | ctx.viRef.current.push(vi) 131 | 132 | return () => { 133 | ctx.viRef.current = ctx.viRef.current.filter(cb => cb !== vi) 134 | } 135 | }, [ctx, vi]) 136 | 137 | return ctx 138 | } 139 | -------------------------------------------------------------------------------- /mamar-web/src/app/InstrumentInput.tsx: -------------------------------------------------------------------------------- 1 | import { ActionButton, ComboBox, Content, Dialog, DialogTrigger, Flex, Form, Heading, Item, NumberField, Section } from "@adobe/react-spectrum" 2 | import { useEffect, useState } from "react" 3 | 4 | import styles from "./InstrumentInput.module.scss" 5 | import * as instruments from "./instruments" 6 | import { useBgm } from "./store" 7 | 8 | function InstrumentComboBox({ bank, patch, onChange }: { 9 | bank: number 10 | patch: number 11 | onChange(partial: { bank: number, patch: number }): void 12 | }) { 13 | const [inputValue, setInputValue] = useState(instruments.getName(bank, patch)) 14 | 15 | useEffect(() => { 16 | setInputValue(instruments.getName(bank, patch)) 17 | }, [bank, patch]) 18 | 19 | return { 25 | if (!key) return 26 | const [bank, patch] = key.toString().split(",").map(n => parseInt(n)) 27 | if (isNaN(bank) || isNaN(patch)) return 28 | onChange({ bank, patch }) 29 | setInputValue(instruments.getName(bank, patch)) 30 | }} 31 | onBlur={() => { 32 | setInputValue(instruments.getName(bank, patch)) 33 | }} 34 | direction="top" 35 | > 36 | {item => ( 37 | 38 | {item => {item.name}} 39 | 40 | )} 41 | 42 | } 43 | 44 | export interface Props { 45 | index: number 46 | onChange(index: number): void 47 | } 48 | 49 | export default function InstrumentInput({ index, onChange }: Props) { 50 | const [bgm, dispatch] = useBgm() 51 | const instrument = bgm?.instruments[index] 52 | 53 | const name = instrument ? instruments.getName(instrument.bank, instrument.patch) : "" 54 | 55 | return 56 | 57 | #{index} {name ? `(${name})` : ""} 58 | 59 | 60 | 61 | 62 | Instrument {index} 63 | 64 | {bgm && } 72 | 73 | 74 | 75 | {instrument && e.preventDefault()}> 76 | 77 | dispatch({ type: "update_instrument", index, partial })} /> 78 | dispatch({ type: "update_instrument", index, partial: { bank } })} /> 79 | dispatch({ type: "update_instrument", index, partial: { patch } })} /> 80 | 81 | 82 | dispatch({ type: "update_instrument", index, partial: { volume } })} /> 83 | dispatch({ type: "update_instrument", index, partial: { pan } })} /> 84 | dispatch({ type: "update_instrument", index, partial: { reverb } })} /> 85 | 86 | 87 | dispatch({ type: "update_instrument", index, partial: { coarseTune } })} /> 88 | dispatch({ type: "update_instrument", index, partial: { fineTune } })} /> 89 | 90 | } 91 | 92 | 93 | 94 | } 95 | -------------------------------------------------------------------------------- /mamar-web/src/app/sbn/songNames.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "names": [ 4 | "Toad Town", 5 | null, 6 | "Normal Battle", 7 | "Special Battle", 8 | "Jr. Troopa Battle", 9 | "Final Bowser Battle", 10 | null, 11 | "Goomba King Battle", 12 | "Koopa Bros. Battle", 13 | "Fake Bowser Battle", 14 | "Tutankoopa Battle", 15 | "Tubba Blubba Battle", 16 | "General Guy Battle", 17 | "Lava Piranha Battle", 18 | "Huff N. Puff Battle", 19 | "Crystal King Battle", 20 | "Goomba Village", 21 | "Pleasant Path", 22 | "Fuzzy Attack", 23 | "Koopa Village", 24 | "Koopa Fortress", 25 | "Dry Dry Outpost", 26 | "Mt. Rugged", 27 | "Dry Dry Desert", 28 | "Dry Dry Ruins", 29 | "Ruins Basement", 30 | "Forever Forest", 31 | "Boo's Mansion", 32 | "Cheerful Boo's Mansion", 33 | "Gusty Gulch", 34 | "Tubba's Manor", 35 | "Tubba Escape", 36 | "Shy Guy Toybox", 37 | "Toybox Train", 38 | "Creepy Toybox", 39 | null, 40 | "Jade Jungle", 41 | "Deep Jungle", 42 | "Yoshi's Village", 43 | "Yoshi's Panic", 44 | "Raphael Raven", 45 | "Mt. Lavalava", 46 | "Volcano Escape", 47 | "Star Way Opens", 48 | "Master Battle", 49 | "Radio Island Sounds", 50 | "Radio Hot Hits", 51 | "Radio Golden Oldies", 52 | "Flower Fields Cloudy", 53 | "Flower Fields Sunny", 54 | "Cloudy Climb", 55 | "Puff Puff Machine", 56 | "Sun Tower Cloudy", 57 | "Sun Tower Sunny", 58 | null, 59 | "Crystal Palace", 60 | "Shiver City", 61 | "Penguin Mystery", 62 | "Shiver Snowfield", 63 | "Shiver Mountain", 64 | "Starborn Valley", 65 | "Merlar Theme", 66 | "Mail Call", 67 | "Peach's Castle Party", 68 | "Chapter End", 69 | "Chapter Start", 70 | "Item Upgrade", 71 | null, 72 | "Phonograph Music", 73 | "Tutankoopa Theme", 74 | "Kammy Koopa Theme", 75 | "Jr. Troopa Theme", 76 | "Bullet Bill Assault", 77 | "Monty Mole Assault", 78 | "Shy Guy Invasion", 79 | "Toad Town Tunnels", 80 | "Whale Theme", 81 | "Forever Forest Warning", 82 | "Yoshi Kids Found", 83 | "Unused Fanfare", 84 | "Goomba King Theme", 85 | "Koopa Bros. Interlude", 86 | "Koopa Bros. Theme", 87 | "Tutankoopa Warning", 88 | "Tutankoopa Revealed", 89 | "Tubba Blubba Theme", 90 | "General Guy Theme", 91 | "Lava Piranha Theme", 92 | "Huff N. Puff Theme", 93 | "Crystal King Theme", 94 | "Blooper Theme", 95 | "Miniboss Battle", 96 | "Monstar Theme", 97 | "Club 64", 98 | "Unused Opening", 99 | "Bowser's Castle Falls", 100 | "Star Haven", 101 | "Shooting Star Summit", 102 | "Starship Theme", 103 | "Star Sanctuary", 104 | "Bowser's Castle", 105 | "Bowser's Castle Caves", 106 | "Bowser Theme", 107 | "Bowser Battle", 108 | "Peach Wishes", 109 | "File Select", 110 | "Main Theme", 111 | "Bowser Attacks", 112 | "Mario Falls", 113 | "Peach Appears", 114 | "The End", 115 | "Recovered Star Rod", 116 | "Twink Theme", 117 | "Stirring Cake", 118 | "Gourmet Guy Freakout", 119 | "Prisoner Peach Theme", 120 | "Peach Mission", 121 | "Peach Sneaking", 122 | "Peach Caught", 123 | "Peach Quiz Intro", 124 | "Star Spirit Theme", 125 | "Penguin Whodunnit", 126 | "Penguin Wakes Up", 127 | "Magic Beanstalk", 128 | "Merlee Spell", 129 | "Lakilester Theme", 130 | "Goomba Bros Retreat", 131 | "Sunshine Returns", 132 | "Riding The Rails", 133 | "Riding The Whale", 134 | "New Partner", 135 | "Dry Dry Ruins Appear", 136 | "Candy Canes", 137 | "Playroom", 138 | "Moustafa Theme", 139 | "Game Over", 140 | "Taking Rest", 141 | "Flower NPC Theme", 142 | "Flower Gate Appears", 143 | "Battle End", 144 | "Pop Diva Song", 145 | "Boo Minigame", 146 | "Level Up", 147 | null, 148 | "Parade Day", 149 | null, 150 | null, 151 | "Mario Bros House", 152 | "Intro Story", 153 | "New Partner JP" 154 | ] 155 | } 156 | -------------------------------------------------------------------------------- /mamar-web/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Paper Mario music editor - Mamar 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | Mamar 25 | 26 | 27 | by alex 28 | 29 | 30 | 31 | 32 | 33 | 34 | Open Mamar 35 | 36 | 37 | 38 | 39 | 40 | 41 | paper mario music editor 42 | 43 | 44 | 45 | 46 | 47 | Mamar is a digital audio workstation, MIDI editor, and music player for Paper Mario on Nintendo 64. 48 | 49 | Included is an emulator which provides identical playback capabilities to the game. 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | With Mamar, you can: 58 | 59 | 60 | 61 | Create songs from scratch 62 | 63 | 64 | Save and open .bgm files 65 | 66 | 67 | Convert .midi files to .bgm 68 | 69 | 70 | Change instrument settings 71 | 72 | 73 | 74 | 75 | Run Mamar online 76 | 77 | 78 | Discord server 79 | 80 | 81 | 82 | 83 | 84 | Contributing 85 | 86 | Mamar is open-source and contributions are welcome! 87 | 88 | 89 | The source code is available on GitHub. 90 | 91 | 92 | 93 | 94 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /mamar-web/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /patches/src/patches.c: -------------------------------------------------------------------------------- 1 | #include "common.h" 2 | #include "audio.h" 3 | #include "audio/private.h" 4 | #include "gcc/memory.h" 5 | 6 | // inverse of AU_FILE_RELATIVE; returns offset from start of file 7 | #define MAMAR_RELATIVE_OFFSET(base, addr) ((void*)((s32)(addr) - (s32)(base))) 8 | 9 | u8 MAMAR_bgm[0x20000]; 10 | s32 MAMAR_bgm_size; 11 | s32 MAMAR_bk_files[3]; 12 | s32 MAMAR_song_id; 13 | s32 MAMAR_song_variation; 14 | s32 MAMAR_ambient_sounds; 15 | s32 MAMAR_trackMute[16]; // 0 = no mute, 1 = mute, 2 = solo 16 | 17 | s32 MAMAR_out_masterTempo; 18 | s32 MAMAR_out_segmentReadPos; 19 | s32 MAMAR_out_trackReadPos[16]; 20 | 21 | void PATCH_state_step_logos(void) { 22 | set_game_mode(GAME_MODE_TITLE_SCREEN); 23 | play_ambient_sounds(AMBIENT_RADIO, 0); 24 | } 25 | 26 | void PATCH_state_step_title_screen(void) { 27 | s32 i; 28 | 29 | bgm_set_song(0, MAMAR_song_id, MAMAR_song_variation, 0, 8); 30 | play_ambient_sounds(MAMAR_ambient_sounds, 0); 31 | 32 | MAMAR_out_masterTempo = gBGMPlayerA->masterTempo; 33 | MAMAR_out_segmentReadPos = MAMAR_RELATIVE_OFFSET(gBGMPlayerA->bgmFile, gBGMPlayerA->segmentReadPos); 34 | for (i = 0; i < 16; i++) { 35 | if (gBGMPlayerA->tracks[i].bgmReadPos != NULL) { 36 | MAMAR_out_trackReadPos[i] = MAMAR_RELATIVE_OFFSET(gBGMPlayerA->bgmFile, gBGMPlayerA->tracks[i].bgmReadPos); 37 | } else { 38 | MAMAR_out_trackReadPos[i] = 0; 39 | } 40 | } 41 | 42 | // MAMAR_trackMute 43 | { 44 | s32 isAnySolo = 0; 45 | 46 | for (i = 0; i < 16; i++) { 47 | if (MAMAR_trackMute[i] == 2) { 48 | isAnySolo = 1; 49 | break; 50 | } 51 | } 52 | 53 | for (i = 0; i < 16; i++) { 54 | s32 volume = 0; 55 | 56 | if (MAMAR_trackMute[i] == 2) { 57 | volume = 100; 58 | } else if (MAMAR_trackMute[i] == 1 || isAnySolo) { 59 | volume = 0; 60 | } else { 61 | volume = 100; 62 | } 63 | 64 | func_80050888(gBGMPlayerA, &gBGMPlayerA->tracks[i], volume, 0); 65 | } 66 | } 67 | } 68 | 69 | void PATCH_appendGfx_title_screen(void) { 70 | } 71 | 72 | AuResult MAMAR_au_load_song_files(u32 songID, BGMHeader* bgmFile, BGMPlayer* player) { 73 | AuResult status; 74 | SBNFileEntry fileEntry; 75 | SBNFileEntry fileEntry2; 76 | SBNFileEntry* bkFileEntry; 77 | AuGlobals* soundData; 78 | InitSongEntry* songInfo; 79 | s32 i; 80 | u16 bkFileIndex; 81 | s32 bgmFileIndex; 82 | u32 data; 83 | u32 offset; 84 | 85 | soundData = gSoundGlobals; 86 | 87 | songInfo = &soundData->songList[songID]; 88 | status = au_fetch_SBN_file(songInfo->bgmFileIndex, AU_FMT_BGM, &fileEntry); 89 | if (status != AU_RESULT_OK) { 90 | return status; 91 | } 92 | 93 | if (func_8004DB28(player)) { 94 | return AU_ERROR_201; 95 | } 96 | 97 | if (MAMAR_bgm_size > 0) { 98 | // Detect endianness of file 99 | s32 be = MAMAR_bgm[0] == 'B' && MAMAR_bgm[1] == 'G' && MAMAR_bgm[2] == 'M' && MAMAR_bgm[3] == ' '; 100 | s32 le = MAMAR_bgm[0] == ' ' && MAMAR_bgm[1] == 'M' && MAMAR_bgm[2] == 'G' && MAMAR_bgm[3] == 'B'; 101 | 102 | if (be) { 103 | au_copy_bytes(MAMAR_bgm, bgmFile, MAMAR_bgm_size); 104 | } else if (le) { 105 | u8* dest = (u8*)bgmFile; 106 | u8* src = MAMAR_bgm; 107 | 108 | for (i = 0; i < MAMAR_bgm_size; i += 4) { 109 | dest[i + 0] = src[i + 3]; 110 | dest[i + 1] = src[i + 2]; 111 | dest[i + 2] = src[i + 1]; 112 | dest[i + 3] = src[i + 0]; 113 | } 114 | } 115 | 116 | // If the "BGM " signature is invalid, play an error song 117 | if (bgmFile->signature != 0x42474D20) { 118 | MAMAR_bgm_size = 0; 119 | return MAMAR_au_load_song_files(SONG_WHALE_THEME, bgmFile, player); 120 | } 121 | } else { 122 | au_read_rom(fileEntry.offset, bgmFile, fileEntry.data & 0xFFFFFF); 123 | } 124 | 125 | for (i = 0 ; i < ARRAY_COUNT(MAMAR_bk_files); i++) { 126 | bkFileIndex = MAMAR_bk_files[i]; 127 | if (bkFileIndex != 0) { 128 | bkFileEntry = &soundData->sbnFileList[bkFileIndex]; 129 | 130 | offset = (bkFileEntry->offset & 0xFFFFFF) + soundData->baseRomOffset; 131 | fileEntry2.offset = offset; 132 | 133 | data = bkFileEntry->data; 134 | fileEntry2.data = data; 135 | 136 | if ((data >> 0x18) == AU_FMT_BK) { 137 | snd_load_BK(offset, i); 138 | } 139 | } 140 | } 141 | player->songID = songID; 142 | player->bgmFile = bgmFile; 143 | player->bgmFileIndex = 0; 144 | return bgmFile->name; 145 | } 146 | 147 | AuResult PATCH_au_load_song_files(u32 songID, BGMHeader* bgmFile, BGMPlayer* player) { 148 | // It has jumps so we can't just use a hook 149 | return MAMAR_au_load_song_files(songID, bgmFile, player); 150 | } 151 | -------------------------------------------------------------------------------- /patches/build.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import os 3 | import subprocess 4 | import re 5 | 6 | this_dir = Path(__file__).parent 7 | 8 | def get_command_from_build_ninja(rule): 9 | papermario = this_dir / "papermario" 10 | build_ninja = papermario / "build.ninja" 11 | 12 | with build_ninja.open("r") as f: 13 | lines = f.readlines() 14 | 15 | command = None 16 | for i, line in enumerate(lines): 17 | if line.startswith(f"rule {rule}"): 18 | command = lines[i + 1] 19 | command = command[command.find("=")+1:].strip() 20 | break 21 | 22 | if not command: 23 | raise Exception(f"Could not find rule '{rule}' in build.ninja") 24 | 25 | if rule == "ld": 26 | command = command.replace(" ", " -T ../src/symbol_addrs.txt ", 1) 27 | 28 | command = "cd papermario && " + command.replace("$version", "us") 29 | 30 | def run(options): 31 | my_command = command 32 | for key, value in options.items(): 33 | my_command = my_command.replace(f"${key}", value) 34 | if os.system(my_command) != 0: 35 | exit(1) 36 | 37 | return run 38 | 39 | def objdump_get_asm(infname): 40 | output = subprocess.check_output(["mips-linux-gnu-objdump", infname, "-d"]).decode("utf-8") 41 | print(output) 42 | lines = output.splitlines() 43 | start_of_func_re = re.compile(r"([0-9a-f]+) <(.+)>:") 44 | funcs_dict = {} 45 | 46 | for i, line in enumerate(lines): 47 | if match := start_of_func_re.match(line): 48 | name = match.group(2) 49 | 50 | asm_list = [] 51 | for i, line in enumerate(lines[i+1:]): 52 | parts = line.split("\t", 2) 53 | if len(parts) < 2 or parts[1] == "...": 54 | break 55 | as_int = int(parts[1].strip(), 16) 56 | as_text = parts[2].replace("\t", " ") 57 | 58 | asm_list.append([ as_int, as_text ]) 59 | 60 | funcs_dict[name] = asm_list 61 | 62 | return funcs_dict 63 | 64 | def objdump_get_symbols(infname): 65 | output = subprocess.check_output(['mips-linux-gnu-objdump', '-x', infname]).decode() 66 | objdump_lines = output.splitlines() 67 | elf_symbols = [] 68 | 69 | for line in objdump_lines: 70 | if " F " in line or " O " in line or " *ABS*" in line: 71 | components = line.split() 72 | name = components[-1] 73 | 74 | if "_ROM_START" in name or "_ROM_END" in name: 75 | continue 76 | 77 | if "/" in name or \ 78 | "." in name or \ 79 | name.startswith("_") or \ 80 | name.startswith("jtbl_") or \ 81 | name.endswith(".o") or \ 82 | re.match(r"L[0-9A-F]{8}", name): 83 | continue 84 | 85 | addr = int(components[0], 16) 86 | if " F " in line or name.startswith("func_"): 87 | type = "func" 88 | else: 89 | type = "data" 90 | 91 | if name.startswith("MAMAR_"): 92 | print(f"{addr:08X} = {name}") 93 | 94 | elf_symbols.append((name, addr, type)) 95 | 96 | return elf_symbols 97 | 98 | cc = get_command_from_build_ninja("cc") 99 | ld = get_command_from_build_ninja("ld") 100 | 101 | cc({ "in": "../src/patches.c", "out": "../build/patches.o" }) 102 | ld({ "in": "../src/patches.ld", "out": "../build/patches.elf", "mapfile": "../build/patches.map" }) 103 | 104 | funcs_dict = objdump_get_asm("build/patches.elf") 105 | symbols = objdump_get_symbols("build/patches.elf") 106 | 107 | print(f"Found {len(funcs_dict)} functions") 108 | print(f"Found {len(symbols)} symbols") 109 | 110 | with (this_dir / "build" / "patches.mjs").open("w") as f: 111 | f.write("// Generated by build.py\n") 112 | 113 | for name, asm in funcs_dict.items(): 114 | f.write("\n") 115 | f.write(f"export const ASM_{name} = new Uint32Array([\n") 116 | for as_int, as_text in asm: 117 | f.write(f" 0x{as_int:08x}, // {as_text}\n") 118 | f.write("])\n") 119 | 120 | f.write("\n") 121 | 122 | for name, addr, type in symbols: 123 | f.write(f"export const RAM_{name} = 0x{addr:08x}\n") 124 | 125 | with (this_dir / "build" / "patches.js").open("w") as f: 126 | f.write("// Generated by build.py\n") 127 | f.write("\n") 128 | f.write("module.exports = {}\n") 129 | 130 | for name, asm in funcs_dict.items(): 131 | f.write("\n") 132 | f.write(f"module.exports.RAM_{name} = new Uint32Array([\n") 133 | for as_int, as_text in asm: 134 | f.write(f" 0x{as_int:08x}, // {as_text}\n") 135 | f.write("])\n") 136 | 137 | f.write("\n") 138 | 139 | for name, addr, type in symbols: 140 | f.write(f"module.exports.ASM_{name} = 0x{addr:08x}\n") 141 | 142 | with (this_dir / "build" / "patches.d.ts").open("w") as f: 143 | f.write("// Generated by build.py\n") 144 | 145 | for name, asm in funcs_dict.items(): 146 | f.write("\n") 147 | f.write(f"export const ASM_{name}: Uint32Array\n") 148 | 149 | f.write("\n") 150 | 151 | for name, addr, type in symbols: 152 | f.write(f"export const RAM_{name}: number\n") 153 | -------------------------------------------------------------------------------- /mamar-web/src/app/doc/SegmentMap.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames" 2 | import { useId, useRef } from "react" 3 | 4 | import Ruler, { ticksToStyle, useSegmentLengths } from "./Ruler" 5 | import styles from "./SegmentMap.module.scss" 6 | import { TimeProvider } from "./timectx" 7 | 8 | import TrackControls from "../emu/TrackControls" 9 | import { useBgm, useDoc, useVariation } from "../store" 10 | import useSelection, { SelectionProvider } from "../util/hooks/useSelection" 11 | 12 | const TRACK_HEAD_WIDTH = 100 // Match with $trackHead-width 13 | 14 | function PianoRollThumbnail({ trackIndex, trackListIndex }: { trackIndex: number, trackListIndex: number }) { 15 | const [doc, dispatch] = useDoc() 16 | const [bgm] = useBgm() 17 | const track = bgm?.trackLists[trackListIndex]?.tracks[trackIndex] 18 | const isSelected = doc?.panelContent.type === "tracker" && doc?.panelContent.trackList === trackListIndex && doc?.panelContent.track === trackIndex 19 | const nameId = useId() 20 | 21 | if (!track || track.commands.vec.length === 0) { 22 | return <>> 23 | } else { 24 | const handlePress = (evt: any) => { 25 | dispatch({ 26 | type: "set_panel_content", 27 | panelContent: isSelected ? { type: "not_open" } : { 28 | type: "tracker", 29 | trackList: trackListIndex, 30 | track: trackIndex, 31 | }, 32 | }) 33 | evt.stopPropagation() 34 | evt.preventDefault() 35 | } 36 | 37 | return { 49 | if (evt.key === "Enter" || evt.key === " ") { 50 | handlePress(evt) 51 | } 52 | }} 53 | > 54 | {track.name} 55 | 56 | } 57 | } 58 | 59 | function TrackName({ index }: { index: number }) { 60 | return 61 | {index === 0 ? "Master" : `Track ${index}`} 62 | 63 | } 64 | 65 | function Container() { 66 | const [variation] = useVariation() 67 | const selection = useSelection() 68 | const segmentLengths = useSegmentLengths() 69 | const container = useRef(null) 70 | 71 | const tracks = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] // TODO: don't show track 0 72 | const totalLength = segmentLengths.reduce((acc, len) => acc + len, 0) 73 | 74 | return totalLength) return totalLength 84 | return ticks 85 | }, 86 | 87 | ticksToXOffset(ticks: number): number { 88 | if (!container.current) return NaN 89 | const style = getComputedStyle(container.current) 90 | const rulerZoom = parseFloat(style.getPropertyValue("--ruler-zoom")) 91 | return ticks / rulerZoom 92 | }, 93 | }}> 94 | { 99 | selection.clear() 100 | }} 101 | > 102 | {variation && 103 | 104 | {tracks.map(i => 105 | { 106 | 107 | {i > 0 && } 108 | } 109 | {variation.segments.map((segment, segmentIndex) => { 110 | if (segment.type === "Subseg") { 111 | return 115 | 116 | 117 | } else { 118 | return 119 | } 120 | })} 121 | )} 122 | } 123 | 124 | 125 | } 126 | 127 | export default function SegmentMap() { 128 | return 129 | 130 | 131 | } 132 | -------------------------------------------------------------------------------- /mamar-web/src/app/store/variation.ts: -------------------------------------------------------------------------------- 1 | import produce from "immer" 2 | import { Segment, Variation } from "pm64-typegen" 3 | 4 | import { useBgm } from "./bgm" 5 | import { useDoc } from "./doc" 6 | import { SegmentAction, segmentReducer } from "./segment" 7 | 8 | function cleanLoops(segments: (Segment | null)[]): Segment[] { 9 | return produce(segments, cleaned => { 10 | // Ensure StartLoop comes before EndLoop with the same label_index 11 | for (let i = 0; i < cleaned.length; i++) { 12 | const endLoop = cleaned[i] 13 | if (endLoop?.type === "EndLoop") { 14 | for (let j = i + 1; j < cleaned.length; j++) { 15 | const startLoop = cleaned[j] 16 | if (startLoop?.type === "StartLoop" && startLoop.label_index === endLoop.label_index) { 17 | const temp = cleaned[i] 18 | cleaned[i] = cleaned[j] 19 | cleaned[j] = temp 20 | break 21 | } 22 | } 23 | } 24 | } 25 | 26 | // Remove adjacent StartLoop and EndLoop with matching label_index 27 | for (let i = 0; i < cleaned.length - 1; i++) { 28 | const a = cleaned[i] 29 | const b = cleaned[i + 1] 30 | if (a?.type === "StartLoop" && 31 | b?.type === "EndLoop" && 32 | a.label_index === b.label_index 33 | ) { 34 | cleaned[i] = null 35 | cleaned[i + 1] = null 36 | } 37 | } 38 | }).filter(s => s !== null) as Segment[] 39 | } 40 | 41 | export type VariationAction = { 42 | type: "segment" 43 | id: number 44 | action: SegmentAction 45 | } | { 46 | type: "move_segment" 47 | id: number 48 | toIndex: number 49 | } | { 50 | type: "add_segment" 51 | id: number 52 | trackList: number 53 | } | { 54 | type: "toggle_segment_loop" 55 | id: number 56 | } 57 | 58 | export function variationReducer(variation: Variation, action: VariationAction): Variation { 59 | switch (action.type) { 60 | case "segment": 61 | return { 62 | ...variation, 63 | segments: variation.segments.map(segment => { 64 | if (segment.id === action.id) { 65 | return segmentReducer(segment, action.action) 66 | } else { 67 | return segment 68 | } 69 | }), 70 | } 71 | case "move_segment": 72 | return produce(variation, draft => { 73 | const fromIndex = draft.segments.findIndex(s => s.id === action.id) 74 | if (fromIndex === -1) return 75 | 76 | const segment = draft.segments[fromIndex] 77 | draft.segments[fromIndex] = null as any 78 | draft.segments.splice(action.toIndex, 0, segment) 79 | 80 | draft.segments = cleanLoops(draft.segments) 81 | }) 82 | case "add_segment": 83 | const newSeg: Segment = { 84 | type: "Subseg", 85 | id: action.id, 86 | trackList: action.trackList, 87 | } 88 | return { 89 | ...variation, 90 | segments: [ 91 | ...variation.segments, 92 | newSeg, 93 | ], 94 | } 95 | case "toggle_segment_loop": { 96 | return produce(variation, draft => { 97 | const i = draft.segments.findIndex(s => s.id === action.id) 98 | if (i === -1) return 99 | 100 | let inLoop = false 101 | let loopStartIndex = -1 102 | let loopEndIndex = -1 103 | 104 | // Traverse backwards to find StartLoops 105 | for (let j = i - 1; j >= 0; j--) { 106 | const s = draft.segments[j] 107 | if (s.type === "StartLoop" && s.label_index !== undefined) { 108 | const startIndex = s.label_index 109 | // Now, search for matching EndLoop after the StartLoop 110 | for (let k = i + 1; k < draft.segments.length; k++) { 111 | const segment = draft.segments[k] 112 | if (segment.type === "EndLoop" && segment.label_index === startIndex) { 113 | inLoop = true 114 | loopStartIndex = j 115 | loopEndIndex = k 116 | break 117 | } 118 | } 119 | if (inLoop) break 120 | } 121 | } 122 | 123 | const nextId = Math.max(...draft.segments.map(s => s.id)) + 1 124 | const nextLabel = Math.max(0, ...draft.segments.map(s => ((s.type === "StartLoop" || s.type === "EndLoop") ? s.label_index ?? 0 : 0))) + 1 125 | 126 | if (inLoop) { 127 | // Remove the loop start/end 128 | draft.segments.splice(loopStartIndex, 1) 129 | draft.segments.splice(loopEndIndex - 1, 1) 130 | } else { 131 | // Create a new loop around this segment 132 | draft.segments.splice(i, 0, { type: "StartLoop", id: nextId, label_index: nextLabel }) 133 | draft.segments.splice(i + 2, 0, { type: "EndLoop", id: nextId + 1, label_index: nextLabel, iter_count: 0 }) 134 | } 135 | 136 | draft.segments = cleanLoops(draft.segments) 137 | }) 138 | } 139 | } 140 | } 141 | 142 | export const useVariation = (index?: number, docId?: string): [Variation | undefined, (action: VariationAction) => void] => { 143 | const [doc] = useDoc() 144 | const [bgm, dispatch] = useBgm(docId) 145 | 146 | if (typeof index === "undefined") { 147 | index = doc?.activeVariation 148 | } 149 | 150 | return [ 151 | bgm?.variations[index as number] ?? undefined, 152 | action => { 153 | if (typeof index === "number") { 154 | dispatch({ 155 | type: "variation", 156 | index, 157 | action, 158 | }) 159 | } 160 | }, 161 | ] 162 | } 163 | -------------------------------------------------------------------------------- /pm64/src/rw.rs: -------------------------------------------------------------------------------- 1 | use std::io::prelude::*; 2 | use std::io::{Error, ErrorKind, Result, SeekFrom}; 3 | 4 | pub trait SeekExt: Seek { 5 | fn pos(&mut self) -> Result; 6 | } 7 | 8 | impl SeekExt for S { 9 | fn pos(&mut self) -> Result { 10 | self.stream_position() 11 | } 12 | } 13 | 14 | pub trait ReadExt: Read { 15 | fn read_u8(&mut self) -> Result; 16 | fn read_i8(&mut self) -> Result; 17 | fn read_u16_be(&mut self) -> Result; 18 | fn read_i16_be(&mut self) -> Result; 19 | fn read_u32_be(&mut self) -> Result; 20 | fn read_padding(&mut self, num_bytes: u32) -> Result<()>; 21 | fn read_cstring(&mut self, max_len: u64) -> Result; // len includes null terminator 22 | } 23 | 24 | impl ReadExt for R { 25 | fn read_u8(&mut self) -> Result { 26 | let mut buffer = [0; 1]; 27 | self.read_exact(&mut buffer)?; 28 | Ok(buffer[0]) 29 | } 30 | 31 | fn read_i8(&mut self) -> Result { 32 | self.read_u8().map(|i| i as i8) 33 | } 34 | 35 | fn read_u16_be(&mut self) -> Result { 36 | let mut buffer = [0; 2]; 37 | self.read_exact(&mut buffer)?; 38 | Ok(u16::from_be_bytes(buffer)) 39 | } 40 | 41 | fn read_i16_be(&mut self) -> Result { 42 | let mut buffer = [0; 2]; 43 | self.read_exact(&mut buffer)?; 44 | Ok(i16::from_be_bytes(buffer)) 45 | } 46 | 47 | fn read_u32_be(&mut self) -> Result { 48 | let mut buffer = [0; 4]; 49 | self.read_exact(&mut buffer)?; 50 | Ok(u32::from_be_bytes(buffer)) 51 | } 52 | 53 | fn read_padding(&mut self, num_bytes: u32) -> Result<()> { 54 | for _ in 0..num_bytes { 55 | if self.read_u8()? != 0 { 56 | return Err(Error::new(ErrorKind::InvalidData, "padding expected")); 57 | } 58 | } 59 | 60 | Ok(()) 61 | } 62 | 63 | fn read_cstring(&mut self, max_len: u64) -> Result { 64 | let buffer: Result> = self.take(max_len).bytes().collect(); 65 | let mut buffer = buffer?; 66 | 67 | // Resize `buffer` up to - but not including - its null terminator (if any) 68 | let mut i = 0; 69 | while i < buffer.len() { 70 | if buffer[i] == 0 { 71 | buffer.truncate(i); 72 | break; 73 | } 74 | 75 | i += 1; 76 | } 77 | 78 | String::from_utf8(buffer).map_err(|err| Error::new(ErrorKind::InvalidData, err)) 79 | } 80 | } 81 | 82 | pub trait WriteExt: Write + Seek { 83 | fn write_u8(&mut self, value: u8) -> Result<()>; 84 | fn write_i8(&mut self, value: i8) -> Result<()>; 85 | fn write_u16_be(&mut self, value: u16) -> Result<()>; 86 | fn write_i16_be(&mut self, value: i16) -> Result<()>; 87 | fn write_u32_be(&mut self, value: u32) -> Result<()>; 88 | fn write_cstring_lossy(&mut self, string: &str, max_len: usize) -> Result<()>; // len includes null terminator 89 | 90 | /// Seeks to a position, writes the value, then seeks back. 91 | fn write_u16_be_at(&mut self, value: u16, pos: SeekFrom) -> Result<()>; 92 | fn write_u32_be_at(&mut self, value: u32, pos: SeekFrom) -> Result<()>; 93 | 94 | /// Seeks forward until the position is aligned to the given alignment. 95 | fn align(&mut self, alignment: u64) -> Result<()>; 96 | } 97 | 98 | impl WriteExt for W { 99 | fn write_u8(&mut self, value: u8) -> Result<()> { 100 | self.write_all(&[value]) 101 | } 102 | 103 | fn write_i8(&mut self, value: i8) -> Result<()> { 104 | self.write_all(&value.to_be_bytes()) 105 | } 106 | 107 | fn write_u16_be(&mut self, value: u16) -> Result<()> { 108 | self.write_all(&value.to_be_bytes()) 109 | } 110 | 111 | fn write_i16_be(&mut self, value: i16) -> Result<()> { 112 | self.write_all(&value.to_be_bytes()) 113 | } 114 | 115 | fn write_u32_be(&mut self, value: u32) -> Result<()> { 116 | self.write_all(&value.to_be_bytes()) 117 | } 118 | 119 | fn write_cstring_lossy(&mut self, string: &str, max_len: usize) -> Result<()> { 120 | let mut bytes: Vec = string 121 | .chars() 122 | .filter(|ch| ch.len_utf8() == 1) 123 | .take(max_len - 1) 124 | .map(|ch| { 125 | let mut b = [0; 1]; 126 | ch.encode_utf8(&mut b); 127 | b[0] 128 | }) 129 | .collect(); 130 | bytes.resize(max_len, 0); // add null terminator(s) to reach `max_len` 131 | self.write_all(&bytes) 132 | } 133 | 134 | fn write_u16_be_at(&mut self, value: u16, pos: SeekFrom) -> Result<()> { 135 | let old_pos = self.pos()?; 136 | self.seek(pos)?; 137 | self.write_u16_be(value)?; 138 | self.seek(SeekFrom::Start(old_pos))?; 139 | Ok(()) 140 | } 141 | 142 | fn write_u32_be_at(&mut self, value: u32, pos: SeekFrom) -> Result<()> { 143 | let old_pos = self.pos()?; 144 | self.seek(pos)?; 145 | self.write_u32_be(value)?; 146 | self.seek(SeekFrom::Start(old_pos))?; 147 | Ok(()) 148 | } 149 | 150 | fn align(&mut self, alignment: u64) -> Result<()> { 151 | let pos = self.pos()?; 152 | 153 | if pos % alignment == 0 { 154 | // Nothing to do 155 | return Ok(()); 156 | } 157 | 158 | // Calculate next multiple of `alignment` 159 | let rounded_pos = pos + alignment; // NEXT multiple, not closest 160 | let new_pos = (rounded_pos / alignment) * alignment; 161 | 162 | // Write zeroes 163 | let delta = new_pos - pos; 164 | for _ in 0..delta { 165 | self.write_u8(0)?; 166 | } 167 | 168 | Ok(()) 169 | } 170 | } 171 | 172 | /// Aligns a value to the next multiple of n. 173 | pub fn align(value: u32, n: u32) -> u32 { 174 | if n <= 1 { 175 | return value; 176 | } 177 | 178 | if value % n == 0 { 179 | n 180 | } else { 181 | value + (n - value % n) 182 | } 183 | } 184 | 185 | #[cfg(test)] 186 | mod test { 187 | use super::*; 188 | 189 | #[test] 190 | fn test_align() { 191 | assert_eq!(align(0, 5), 5); 192 | assert_eq!(align(5, 5), 5); 193 | assert_eq!(align(6, 5), 10); 194 | 195 | // 0 and 1 values for `n` should be a no-op 196 | assert_eq!(align(36, 0), 36); 197 | assert_eq!(align(36, 1), 36); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /n64crc/n64crc.c: -------------------------------------------------------------------------------- 1 | /* snesrc - SNES Recompiler 2 | * 3 | * Mar 23, 2010: addition by spinout to actually fix CRC if it is incorrect 4 | * 5 | * Copyright notice for this file: 6 | * Copyright (C) 2005 Parasyte 7 | * 8 | * Based on uCON64's N64 checksum algorithm by Andreas Sterbenz 9 | * 10 | * This program is free software; you can redistribute it and/or modify 11 | * it under the terms of the GNU General Public License as published by 12 | * the Free Software Foundation; either version 2 of the License, or 13 | * (at your option) any later version. 14 | * 15 | * This program is distributed in the hope that it will be useful, 16 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | * GNU General Public License for more details. 19 | * 20 | * You should have received a copy of the GNU General Public License 21 | * along with this program; if not, write to the Free Software 22 | * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 23 | */ 24 | 25 | #include 26 | #include 27 | 28 | #ifdef EMSCRIPTEN 29 | #include 30 | #endif 31 | 32 | #define ROL(i, b) (((i) << (b)) | ((i) >> (32 - (b)))) 33 | #define BYTES2LONG(b) ( (b)[0] << 24 | \ 34 | (b)[1] << 16 | \ 35 | (b)[2] << 8 | \ 36 | (b)[3] ) 37 | 38 | #define N64_HEADER_SIZE 0x40 39 | #define N64_BC_SIZE (0x1000 - N64_HEADER_SIZE) 40 | 41 | #define N64_CRC1 0x10 42 | #define N64_CRC2 0x14 43 | 44 | #define CHECKSUM_START 0x00001000 45 | #define CHECKSUM_LENGTH 0x00100000 46 | #define CHECKSUM_CIC6102 0xF8CA4DDC 47 | #define CHECKSUM_CIC6103 0xA3886759 48 | #define CHECKSUM_CIC6105 0xDF26F436 49 | #define CHECKSUM_CIC6106 0x1FEA617A 50 | 51 | #define Write32(Buffer, Offset, Value)\ 52 | Buffer[Offset] = (Value & 0xFF000000) >> 24;\ 53 | Buffer[Offset + 1] = (Value & 0x00FF0000) >> 16;\ 54 | Buffer[Offset + 2] = (Value & 0x0000FF00) >> 8;\ 55 | Buffer[Offset + 3] = (Value & 0x000000FF);\ 56 | 57 | unsigned int crc_table[256]; 58 | 59 | void gen_table(void) { 60 | unsigned int crc, poly; 61 | int i, j; 62 | 63 | poly = 0xEDB88320; 64 | for (i = 0; i < 256; i++) { 65 | crc = i; 66 | for (j = 8; j > 0; j--) { 67 | if (crc & 1) crc = (crc >> 1) ^ poly; 68 | else crc >>= 1; 69 | } 70 | crc_table[i] = crc; 71 | } 72 | } 73 | 74 | unsigned int crc32(unsigned char *data, int len) { 75 | unsigned int crc = ~0; 76 | int i; 77 | 78 | for (i = 0; i < len; i++) { 79 | crc = (crc >> 8) ^ crc_table[(crc ^ data[i]) & 0xFF]; 80 | } 81 | 82 | return ~crc; 83 | } 84 | 85 | 86 | int N64GetCIC(unsigned char *data) { 87 | switch (crc32(&data[N64_HEADER_SIZE], N64_BC_SIZE)) { 88 | case 0x6170A4A1: return 6101; 89 | case 0x90BB6CB5: return 6102; 90 | case 0x0B050EE0: return 6103; 91 | case 0x98BC2C86: return 6105; 92 | case 0xACC8580A: return 6106; 93 | } 94 | 95 | return 6105; 96 | } 97 | 98 | int N64CalcCRC(unsigned int *crc, unsigned char *data) { 99 | int bootcode, i; 100 | unsigned int seed; 101 | 102 | unsigned int t1, t2, t3; 103 | unsigned int t4, t5, t6; 104 | unsigned int r, d; 105 | 106 | 107 | switch ((bootcode = N64GetCIC(data))) { 108 | case 6101: 109 | case 6102: 110 | seed = CHECKSUM_CIC6102; 111 | break; 112 | case 6103: 113 | seed = CHECKSUM_CIC6103; 114 | break; 115 | case 6105: 116 | seed = CHECKSUM_CIC6105; 117 | break; 118 | case 6106: 119 | seed = CHECKSUM_CIC6106; 120 | break; 121 | default: 122 | return 1; 123 | } 124 | 125 | t1 = t2 = t3 = t4 = t5 = t6 = seed; 126 | 127 | i = CHECKSUM_START; 128 | while (i < (CHECKSUM_START + CHECKSUM_LENGTH)) { 129 | d = BYTES2LONG(&data[i]); 130 | if ((t6 + d) < t6) t4++; 131 | t6 += d; 132 | t3 ^= d; 133 | r = ROL(d, (d & 0x1F)); 134 | t5 += r; 135 | if (t2 > d) t2 ^= r; 136 | else t2 ^= t6 ^ d; 137 | 138 | if (bootcode == 6105) t1 += BYTES2LONG(&data[N64_HEADER_SIZE + 0x0710 + (i & 0xFF)]) ^ d; 139 | else t1 += t5 ^ d; 140 | 141 | i += 4; 142 | break; 143 | } 144 | printf("i: %d, d: %d, t1: %d, t2: %d, t3: %d, t4: %d, t5: %d, t6: %d\n", i, d, t1, t2, t3, t4, t5, t6); 145 | if (bootcode == 6103) { 146 | crc[0] = (t6 ^ t4) + t3; 147 | crc[1] = (t5 ^ t2) + t1; 148 | } 149 | else if (bootcode == 6106) { 150 | crc[0] = (t6 * t4) + t3; 151 | crc[1] = (t5 * t2) + t1; 152 | } 153 | else { 154 | crc[0] = t6 ^ t4 ^ t3; 155 | crc[1] = t5 ^ t2 ^ t1; 156 | } 157 | 158 | return 0; 159 | } 160 | 161 | #ifdef EMSCRIPTEN 162 | void EMSCRIPTEN_KEEPALIVE print(unsigned char *buffer) { 163 | #else 164 | int main(int argc, char **argv) { 165 | FILE *fin; 166 | unsigned char *buffer; 167 | #endif 168 | int cic; 169 | unsigned int crc[2]; 170 | 171 | //Init CRC algorithm 172 | gen_table(); 173 | 174 | #ifndef EMSCRIPTEN 175 | //Check args 176 | if (argc != 2) { 177 | printf("Usage: n64sums \n"); 178 | return 1; 179 | } 180 | 181 | //Open file 182 | if (!(fin = fopen(argv[1], "r+b"))) { 183 | printf("Unable to open \"%s\" in mode \"%s\"\n", argv[1], "r+b"); 184 | return 1; 185 | } 186 | 187 | //Allocate memory 188 | if (!(buffer = (unsigned char*)malloc((CHECKSUM_START + CHECKSUM_LENGTH)))) { 189 | printf("Unable to allocate %d bytes of memory\n", (CHECKSUM_START + CHECKSUM_LENGTH)); 190 | fclose(fin); 191 | return 1; 192 | } 193 | 194 | //Read data 195 | if (fread(buffer, 1, (CHECKSUM_START + CHECKSUM_LENGTH), fin) != (CHECKSUM_START + CHECKSUM_LENGTH)) { 196 | printf("Unable to read %d bytes of data (invalid N64 image?)\n", (CHECKSUM_START + CHECKSUM_LENGTH)); 197 | fclose(fin); 198 | free(buffer); 199 | return 1; 200 | } 201 | #endif 202 | 203 | //Check CIC BootChip 204 | cic = N64GetCIC(buffer); 205 | printf("BootChip: "); 206 | printf((cic ? "CIC-NUS-%d\n" : "Unknown\n"), cic); 207 | 208 | //Calculate CRC 209 | if (N64CalcCRC(crc, buffer)) { 210 | printf("Unable to calculate CRC\n"); 211 | } 212 | else { 213 | printf("CRC 1: 0x%08X ", BYTES2LONG(&buffer[N64_CRC1])); 214 | printf("Calculated: 0x%08X ", crc[0]); 215 | if (crc[0] == BYTES2LONG(&buffer[N64_CRC1])) 216 | printf("(Good)\n"); 217 | else{ 218 | Write32(buffer, N64_CRC1, crc[0]); 219 | #ifndef EMSCRIPTEN 220 | fseek(fin, N64_CRC1, SEEK_SET); 221 | fwrite(&buffer[N64_CRC1], 1, 4, fin); 222 | #endif 223 | printf("(Bad, fixed)\n"); 224 | } 225 | 226 | printf("CRC 2: 0x%08X ", BYTES2LONG(&buffer[N64_CRC2])); 227 | printf("Calculated: 0x%08X ", crc[1]); 228 | if (crc[1] == BYTES2LONG(&buffer[N64_CRC2])) 229 | printf("(Good)\n"); 230 | else{ 231 | Write32(buffer, N64_CRC2, crc[1]); 232 | #ifndef EMSCRIPTEN 233 | fseek(fin, N64_CRC2, SEEK_SET); 234 | fwrite(&buffer[N64_CRC2], 1, 4, fin); 235 | #endif 236 | printf("(Bad, fixed)\n"); 237 | } 238 | } 239 | 240 | #ifndef EMSCRIPTEN 241 | fclose(fin); 242 | free(buffer); 243 | 244 | return 0; 245 | #endif 246 | } 247 | -------------------------------------------------------------------------------- /pm64/tests/bin/extract.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from sys import argv 4 | from os import path 5 | 6 | dirname = path.dirname(__file__) 7 | 8 | songs = [ 9 | (0xF007C0, "Battle_Fanfare_02.bin"), 10 | (0xF02160, "Hey_You_03.bin"), 11 | (0xF03740, "The_Goomba_King_s_Decree_07.bin"), 12 | (0xF043F0, "Attack_of_the_Koopa_Bros_08.bin"), 13 | (0xF073C0, "Trojan_Bowser_09.bin"), 14 | (0xF08D40, "Chomp_Attack_0A.bin"), 15 | (0xF09600, "Ghost_Gulping_0B.bin"), 16 | (0xF0A550, "Keeping_Pace_0C.bin"), 17 | (0xF0BAE0, "Go_Mario_Go_0D.bin"), 18 | (0xF0DEC0, "Huffin_and_Puffin_0E.bin"), 19 | (0xF0FD20, "Freeze_0F.bin"), 20 | (0xF110D0, "Winning_a_Battle_8B.bin"), 21 | (0xF116C0, "Winning_a_Battle_and_Level_Up_8E.bin"), 22 | (0xF12320, "Jr_Troopa_Battle_04.bin"), 23 | (0xF13C20, "Final_Bowser_Battle_interlude_05.bin"), 24 | (0xF15F40, "Master_Battle_2C.bin"), 25 | (0xF16F80, "Game_Over_87.bin"), 26 | (0xF171D0, "Resting_at_the_Toad_House_88.bin"), 27 | (0xF17370, "Running_around_the_Heart_Pillar_in_Ch1_84.bin"), 28 | (0xF17570, "Tutankoopa_s_Warning_45.bin"), 29 | (0xF18940, "Kammy_Koopa_s_Theme_46.bin"), 30 | (0xF193D0, "Jr_Troopa_s_Theme_47.bin"), 31 | (0xF19BC0, "Goomba_King_s_Theme_50.bin"), 32 | (0xF1A6F0, "Koopa_Bros_Defeated_51.bin"), 33 | (0xF1ABD0, "Koopa_Bros_Theme_52.bin"), 34 | (0xF1C810, "Tutankoopa_s_Warning_2_53.bin"), 35 | (0xF1DBF0, "Tutankoopa_s_Theme_54.bin"), 36 | (0xF1F2E0, "Tubba_Blubba_s_Theme_55.bin"), 37 | (0xF20FF0, "General_Guy_s_Theme_56.bin"), 38 | (0xF21780, "Lava_Piranha_s_Theme_57.bin"), 39 | (0xF22A00, "Huff_N_Puff_s_Theme_58.bin"), 40 | (0xF23A00, "Crystal_King_s_Theme_59.bin"), 41 | (0xF24810, "Blooper_s_Theme_5A.bin"), 42 | (0xF25240, "Midboss_Theme_5B.bin"), 43 | (0xF26260, "Monstar_s_Theme_5C.bin"), 44 | (0xF27840, "Moustafa_s_Theme_86.bin"), 45 | (0xF27E20, "Fuzzy_Searching_Minigame_85.bin"), 46 | (0xF28E20, "Phonograph_in_Mansion_44.bin"), 47 | (0xF29AC0, "Toad_Town_00.bin"), 48 | (0xF2E130, "Bill_Blaster_Theme_48.bin"), 49 | (0xF2EF90, "Monty_Mole_Theme_in_Flower_Fields_49.bin"), 50 | (0xF30590, "Shy_Guys_in_Toad_Town_4A.bin"), 51 | (0xF318B0, "Whale_s_Problem_4C.bin"), 52 | (0xF32220, "Toad_Town_Sewers_4B.bin"), 53 | (0xF33060, "Unused_Theme_4D.bin"), 54 | (0xF33AA0, "Mario_s_House_Prologue_3E.bin"), 55 | (0xF33F10, "Peach_s_Party_3F.bin"), 56 | (0xF354E0, "Goomba_Village_01.bin"), 57 | (0xF35ED0, "Pleasant_Path_11.bin"), 58 | (0xF36690, "Fuzzy_s_Took_My_Shell_12.bin"), 59 | (0xF379E0, "Koopa_Village_13.bin"), 60 | (0xF38570, "Koopa_Bros_Fortress_14.bin"), 61 | (0xF39160, "Dry_Dry_Ruins_18.bin"), 62 | (0xF3A0D0, "Dry_Dry_Ruins_Mystery_19.bin"), 63 | (0xF3A450, "Mt_Rugged_16.bin"), 64 | (0xF3AF20, "Dry_Dry_Desert_Oasis_17.bin"), 65 | (0xF3C130, "Dry_Dry_Outpost_15.bin"), 66 | (0xF3CCC0, "Forever_Forest_1A.bin"), 67 | (0xF3E130, "Boo_s_Mansion_1B.bin"), 68 | (0xF3F3E0, "Bow_s_Theme_1C.bin"), 69 | (0xF40F00, "Gusty_Gulch_Adventure_1D.bin"), 70 | (0xF42F30, "Tubba_Blubba_s_Castle_1E.bin"), 71 | (0xF45500, "The_Castle_Crumbles_1F.bin"), 72 | (0xF465E0, "Shy_Guy_s_Toy_Box_20.bin"), 73 | (0xF474A0, "Toy_Train_Travel_21.bin"), 74 | (0xF47E10, "Big_Lantern_Ghost_s_Theme_22.bin"), 75 | (0xF48410, "Jade_Jungle_24.bin"), 76 | (0xF4A880, "Deep_Jungle_25.bin"), 77 | (0xF4BC00, "Lavalava_Island_26.bin"), 78 | (0xF4E690, "Search_for_the_Fearsome_5_27.bin"), 79 | (0xF50A00, "Raphael_the_Raven_28.bin"), 80 | (0xF52520, "Hot_Times_in_Mt_Lavalava_29.bin"), 81 | (0xF55C80, "Escape_from_Mt_Lavalava_2A.bin"), 82 | (0xF58ED0, "Cloudy_Climb_32.bin"), 83 | (0xF592B0, "Puff_Puff_Machine_33.bin"), 84 | (0xF5AFF0, "Flower_Fields_30.bin"), 85 | (0xF5C8D0, "Flower_Fields_Sunny_31.bin"), 86 | (0xF5DF40, "Sun_s_Tower_34.bin"), 87 | (0xF5F500, "Sun_s_Celebration_35.bin"), 88 | (0xF61700, "Shiver_City_38.bin"), 89 | (0xF62E50, "Detective_Mario_39.bin"), 90 | (0xF64220, "Snow_Road_3A.bin"), 91 | (0xF64CB0, "Over_Shiver_Mountain_3B.bin"), 92 | (0xF65B30, "Starborn_Valley_3C.bin"), 93 | (0xF66690, "Sanctuary_3D.bin"), 94 | (0xF66B70, "Crystal_Palace_37.bin"), 95 | (0xF67F80, "Star_Haven_60.bin"), 96 | (0xF69640, "Shooting_Star_Summit_61.bin"), 97 | (0xF6A050, "Legendary_Star_Ship_62.bin"), 98 | (0xF6C270, "Star_Sanctuary_63.bin"), 99 | (0xF6CED0, "Bowser_s_Castle___Caves_65.bin"), 100 | (0xF6EE40, "Bowser_s_Castle_64.bin"), 101 | (0xF73390, "Star_Elevator_2B.bin"), 102 | (0xF751F0, "Goomba_Bros_Defeated_7E.bin"), 103 | (0xF759C0, "Farewell_Twink_70.bin"), 104 | (0xF77200, "Peach_Cooking_71.bin"), 105 | (0xF77680, "Gourmet_Guy_72.bin"), 106 | (0xF78600, "Hope_on_the_Balcony_Peach_1_73.bin"), 107 | (0xF79070, "Peach_s_Theme_2_74.bin"), 108 | (0xF7A0C0, "Peach_Sneaking_75.bin"), 109 | (0xF7AA40, "Peach_Captured_76.bin"), 110 | (0xF7AD90, "Quiz_Show_Intro_77.bin"), 111 | (0xF7BEA0, "Unconscious_Mario_78.bin"), 112 | (0xF7C780, "Petunia_s_Theme_89.bin"), 113 | (0xF7DC00, "Flower_Fields_Door_appears_8A.bin"), 114 | (0xF7E190, "Beanstalk_7B.bin"), 115 | (0xF7EE20, "Lakilester_s_Theme_7D.bin"), 116 | (0xF80230, "The_Sun_s_Back_7F.bin"), 117 | (0xF81260, "Shiver_City_in_Crisis_79.bin"), 118 | (0xF82460, "Solved_Shiver_City_Mystery_7A.bin"), 119 | (0xF82D00, "Merlon_s_Spell_7C.bin"), 120 | (0xF83DC0, "Bowser_s_Theme_66.bin"), 121 | (0xF85590, "Train_Travel_80.bin"), 122 | (0xF860E0, "Whale_Trip_81.bin"), 123 | (0xF87000, "Chanterelle_s_Song_8C.bin"), 124 | (0xF87610, "Boo_s_Game_8D.bin"), 125 | (0xF88B30, "Dry_Dry_Ruins_rises_up_83.bin"), 126 | (0xF89570, "End_of_Chapter_40.bin"), 127 | (0xF8AAF0, "Beginning_of_Chapter_41.bin"), 128 | (0xF8B820, "Hammer_and_Jump_Upgrade_42.bin"), 129 | (0xF8BD90, "Found_Baby_Yoshi_s_4E.bin"), 130 | (0xF8C360, "New_Partner_JAP_96.bin"), 131 | (0xF8D110, "Unused_YI_Fanfare_4F.bin"), 132 | (0xF8D3E0, "Unused_YI_Fanfare_2_5D.bin"), 133 | (0xF90880, "Peach_s_Castle_inside_Bubble_5E.bin"), 134 | (0xF92A50, "Angry_Bowser_67.bin"), 135 | (0xF95510, "Bowser_s_Castle_explodes_5F.bin"), 136 | (0xF96280, "Peach_s_Wish_68.bin"), 137 | (0xF98520, "File_Select_69.bin"), 138 | (0xF98F90, "Title_Screen_6A.bin"), 139 | (0xF9B830, "Peach_s_Castle_in_Crisis_6B.bin"), 140 | (0xF9D3B0, "Mario_falls_from_Bowser_s_Castle_6C.bin"), 141 | (0xF9D690, "Peach_s_Arrival_6D.bin"), 142 | (0xF9EF30, "Star_Rod_Recovered_6F.bin"), 143 | (0xF9FA30, "Mario_s_House_94.bin"), 144 | (0xFA08A0, "Bowser_s_Attacks_95.bin"), 145 | (0xFA3C60, "End_Parade_1_90.bin"), 146 | (0xFA85F0, "End_Parade_2_91.bin"), 147 | (0xFABE90, "The_End_6E.bin"), 148 | (0xFACC80, "Koopa_Radio_Station_2D.bin"), 149 | (0xFAD210, "The_End_Low_Frequency__2E.bin"), 150 | (0xFAD8F0, "SMW_Remix_2F.bin"), 151 | (0xFADE70, "New_Partner_82.bin"), 152 | (0xFAE860, None), 153 | ] 154 | 155 | if __name__ == "__main__": 156 | if len(argv) != 2: 157 | print("usage: ./extract.py [rom]") 158 | exit(1) 159 | 160 | with open(argv[1], "rb") as rom: 161 | # BGM 162 | for i, row in enumerate(songs): 163 | start, filename = row 164 | 165 | if not filename: 166 | continue 167 | 168 | end = songs[i + 1][0] 169 | 170 | length = end - start 171 | assert length >= 0 172 | 173 | with open(path.join(dirname, filename), "wb") as file: 174 | rom.seek(start) 175 | file.write(rom.read(length)) 176 | 177 | print(filename) 178 | 179 | # SBN 180 | with open(path.join(dirname, "sbn.bin"), "wb") as file: 181 | rom.seek(0xF00000) 182 | file.write(rom.read(0x1942C40)) 183 | print("sbn.bin") 184 | -------------------------------------------------------------------------------- /mamar-web/src/index.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --grey-9: #efefef; 3 | --grey-8: #c8c8c8; 4 | --grey-7: #a2a2a2; 5 | --grey-6: #7c7c7c; 6 | --grey-5: #5c5c5c; 7 | --grey-4: #494949; 8 | --grey-3: #393939; 9 | --grey-2: #2c2c2c; 10 | --grey-1: #1e1e1e; 11 | --grey-0: #111111; 12 | --grey-0-alpha-50: #11111180; 13 | 14 | // Invert grey scale for light mode 15 | @media (prefers-color-scheme: light) { 16 | --grey-9: #080808; 17 | --grey-8: #1e1e1e; 18 | --grey-7: #2c2c2c; 19 | --grey-6: #393939; 20 | --grey-5: #494949; 21 | --grey-4: #5c5c5c; 22 | --grey-3: #7c7c7c; 23 | --grey-2: #a2a2a2; 24 | --grey-1: #c8c8c8; 25 | --grey-0: #ffffff; 26 | --grey-0-alpha-50: #ffffff80; 27 | } 28 | 29 | --yellow: #ffc639; 30 | --pink: #ea7aa1; 31 | } 32 | 33 | * { 34 | box-sizing: border-box; 35 | } 36 | 37 | html { 38 | font-family: SF Pro, system-ui, sans-serif; 39 | font-size: 14px; 40 | } 41 | 42 | body { 43 | color: var(--grey-9); 44 | background: var(--grey-0); 45 | 46 | margin: 0; 47 | padding: 0; 48 | } 49 | 50 | .no-scroll { 51 | overflow: hidden; 52 | } 53 | 54 | ::selection { 55 | color: #000; 56 | background: var(--yellow); 57 | } 58 | 59 | .icon { 60 | display: inline-block; 61 | 62 | width: 1.25em; 63 | height: 1.25em; 64 | 65 | vertical-align: top; 66 | } 67 | 68 | // Used for app-wide loading and error states 69 | .initial-load-container { 70 | display: flex; 71 | align-items: center; 72 | justify-content: center; 73 | 74 | text-align: center; 75 | 76 | height: 100vh; 77 | 78 | h1 { 79 | font-weight: 500; 80 | 81 | img { 82 | filter: grayscale(1); 83 | } 84 | } 85 | 86 | p { 87 | max-width: 60ch; 88 | width: calc(100vw - 1em); 89 | 90 | a:any-link { 91 | color: var(--yellow); 92 | } 93 | } 94 | 95 | .error-details { 96 | color: var(--grey-6); 97 | text-align: left; 98 | font-family: monospace; 99 | } 100 | } 101 | 102 | #canvas { 103 | display: none; 104 | } 105 | 106 | .flex-grow { 107 | flex-grow: 1; 108 | } 109 | 110 | html, .App { 111 | background: var(--grey-0); 112 | } 113 | 114 | @mixin faded-dot-background($height) { 115 | background: linear-gradient(180deg,transparent 0, var(--grey-0) $height), 116 | fixed 0 0 /20px 20px radial-gradient(var(--grey-2) 1px,transparent 0), 117 | fixed 10px 10px / 20px 20px radial-gradient(var(--grey-2) 1px,transparent 0), 118 | var(--grey-0); 119 | } 120 | 121 | .initial-load-container, 122 | #splash-page { 123 | @include faded-dot-background(600px); 124 | } 125 | 126 | #splash-page { 127 | header { 128 | $buttonHeight: 2.25em; 129 | 130 | padding: 0.3em 1em; 131 | height: calc($buttonHeight + 1em); 132 | 133 | background: var(--grey-0-alpha-50); 134 | backdrop-filter: blur(10px); 135 | 136 | border-bottom: 1px solid var(--grey-2); 137 | 138 | position: sticky; 139 | top: 0; 140 | width: 100%; 141 | height: 40px; 142 | z-index: 1; 143 | 144 | h1 { 145 | margin: 0; 146 | padding: 0; 147 | 148 | font-size: 1.25em; 149 | font-weight: 400; 150 | 151 | display: inline-block; 152 | line-height: calc($buttonHeight / 1.3); 153 | 154 | .icon { 155 | vertical-align: sub 156 | } 157 | } 158 | 159 | .author { 160 | margin: 0 0.5em; 161 | 162 | color: var(--grey-8); 163 | 164 | a { 165 | color: var(--grey-9); 166 | 167 | text-decoration: none; 168 | border-bottom: 1.5px solid currentColor; 169 | } 170 | } 171 | 172 | .right { 173 | float: right; 174 | 175 | display: flex; 176 | align-items: center; 177 | gap: 1em; 178 | 179 | height: $buttonHeight; 180 | } 181 | 182 | .button { 183 | height: $buttonHeight; 184 | text-decoration: none; 185 | } 186 | } 187 | 188 | .hero { 189 | text-align: center; 190 | 191 | padding: 6em 2em 0 2em; 192 | } 193 | 194 | .hero-logo { 195 | margin: 0; 196 | padding: 0; 197 | 198 | width: 24em; 199 | max-width: 100%; 200 | 201 | aspect-ratio: 56 / 15; 202 | } 203 | 204 | .hero-description { 205 | font-size: 2.1em; 206 | font-family: SF Pro Rounded, system-ui, sans-serif; 207 | } 208 | 209 | @media (max-width: 600px) { 210 | .hero-description { 211 | font-size: 24px; 212 | } 213 | } 214 | 215 | a { 216 | color: inherit; 217 | } 218 | 219 | .link, 220 | p a { 221 | display: inline-block; 222 | 223 | color: inherit; 224 | 225 | font-weight: 500; 226 | 227 | &:hover { 228 | color: var(--yellow); 229 | } 230 | 231 | &:active { 232 | transform: translateY(1px); 233 | } 234 | } 235 | 236 | main, .section-container { 237 | padding: 1em; 238 | margin: 0 auto; 239 | 240 | max-width: 36em; 241 | 242 | p, ul, hr { 243 | margin-inline: auto; 244 | max-width: 30em; 245 | 246 | font-size: 16px; 247 | line-height: 1.6; 248 | } 249 | 250 | hr { 251 | border: 0; 252 | border-top: 1px solid var(--grey-2); 253 | margin-block: 2em; 254 | } 255 | 256 | small { 257 | color: var(--grey-8); 258 | } 259 | 260 | section:not(:first-child) { 261 | margin: 4em 0; 262 | } 263 | } 264 | 265 | .button { 266 | display: inline-flex; 267 | align-items: center; 268 | justify-content: center; 269 | 270 | padding: 0.5em 1.25em; 271 | 272 | border: 1px solid var(--grey-3); 273 | border-bottom-width: 2px; 274 | border-radius: 4px; 275 | 276 | text-decoration: none; 277 | text-align: center; 278 | font-weight: 600; 279 | user-select: none; 280 | 281 | &:hover { 282 | background: var(--grey-1); 283 | } 284 | 285 | &:active { 286 | transform: translateY(1px); 287 | } 288 | } 289 | 290 | .big { 291 | padding: 0.75em 2em; 292 | } 293 | 294 | .cta { 295 | color: #000; 296 | background: var(--pink); 297 | border-color: rgb(172, 83, 114); 298 | 299 | &:hover { 300 | background: mix(#ea7aa1, #000, 90%); 301 | } 302 | } 303 | 304 | .buttons { 305 | display: flex; 306 | align-items: center; 307 | gap: 1em; 308 | } 309 | 310 | .center { 311 | text-align: center; 312 | } 313 | 314 | .screenshot { 315 | $width: 1115; 316 | $height: 769; 317 | 318 | display: block; 319 | margin: 0 auto; 320 | 321 | width: 100%; 322 | max-width: $width; 323 | aspect-ratio: #{$width} / #{$height}; 324 | 325 | background: url(screenshot.png) no-repeat top center / cover; 326 | 327 | @media (max-width: 800px) { 328 | background-size: calc($width * 0.7) calc($height * 0.7); 329 | 330 | position: relative; 331 | 332 | &::after { 333 | content: ''; 334 | position: absolute; 335 | bottom: 0; 336 | width: 100%; 337 | height: 100px; 338 | background: linear-gradient(transparent, var(--grey-0)); 339 | } 340 | } 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /mamar-web/src/app/doc/SubsegDetails.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, View, Form, Switch, NumberField, ContextualHelp, Heading, Content, Text, Footer, Flex, RadioGroup, Radio, TextField } from "@adobe/react-spectrum" 2 | import { useEffect, useId, useState } from "react" 3 | import { useDebounce } from "use-debounce" 4 | 5 | import styles from "./SubsegDetails.module.scss" 6 | import Tracker from "./Tracker" 7 | 8 | import { useBgm } from "../store" 9 | 10 | export interface Props { 11 | trackListId: number 12 | trackIndex: number 13 | } 14 | 15 | function polyphonicIdxToVoiceCount(polyphonicIdx: number): number { 16 | // player->unk_22A 17 | switch (polyphonicIdx) { 18 | case 1: return 1 19 | case 5: return 2 20 | case 6: return 3 21 | case 7: return 4 22 | default: return 0 23 | } 24 | } 25 | 26 | function voiceCountToPolyphonicIdx(voiceCount: number): number { 27 | switch (voiceCount) { 28 | case 1: return 1 29 | case 2: return 5 30 | case 3: return 6 31 | case 4: return 7 32 | default: return 0 33 | } 34 | } 35 | 36 | export default function SubsegDetails({ trackListId, trackIndex }: Props) { 37 | const hid = useId() 38 | const [bgm, dispatch] = useBgm() 39 | const track = bgm?.trackLists[trackListId]?.tracks[trackIndex] 40 | 41 | // Track name editing is debounced to prevent dispatch spam when typing 42 | const [name, setName] = useState(track?.name) 43 | const [debouncedName] = useDebounce(name, 500) 44 | useEffect(() => { 45 | if (track?.name !== debouncedName) 46 | dispatch({ type: "modify_track_settings", trackList: trackListId, track: trackIndex, name: debouncedName }) 47 | }, [debouncedName, dispatch, trackIndex, trackListId, track?.name]) 48 | 49 | if (!track) { 50 | return Track not found 51 | } 52 | 53 | return 57 | 58 | Region Settings 59 | e.preventDefault()}> 60 | 65 | dispatch({ type: "modify_track_settings", trackList: trackListId, track: trackIndex, isDisabled: !v })}>Enabled 66 | {trackIndex !== 0 ? <> 67 | dispatch({ type: "modify_track_settings", trackList: trackListId, track: trackIndex, isDrumTrack })}>Percussion 68 | { 69 | dispatch({ type: "modify_track_settings", trackList: trackListId, track: trackIndex, polyphonicIdx, parentTrackIdx }) 70 | }} /> 71 | > : <>>} 72 | 73 | 74 | 75 | 76 | 77 | 78 | } 79 | 80 | function PolyphonyForm({ polyphonicIdx, parentTrackIdx, maxParentTrackIdx, onChange }: { polyphonicIdx: number, parentTrackIdx: number, maxParentTrackIdx: number, onChange: (polyphonicIdx: number, parentTrackIdx: number) => void }) { 81 | const polyphonyLabel = 82 | Polyphony 83 | 84 | Understanding Polyphony 85 | 86 | 87 | Polyphony controls how many notes a region can play at the same time. 88 | Each note requires a voice. 89 | For example, if a region has 1 voice, playing a new note will cut off any held one. 90 | 91 | 92 | 98 | 99 | 100 | 101 | const takeoverLabel = 102 | Track to take over 103 | 104 | Conditional Takeover 105 | 106 | 107 | This track stays silent by default. When bgm_set_variation is called with 108 | a non-zero variation, this track performs in place of the selected track, which becomes silent. 109 | The takeover takes place over a 2 beat crossfade. Tracks can only take over tracks that are above them. 110 | 111 | 112 | 119 | 120 | 121 | 122 | let state = "manual" 123 | if (polyphonicIdx === 255) state = "auto" 124 | if (parentTrackIdx !== 0) state = "parent" 125 | 126 | // Store parent track between states, e.g. so that parent->manual->parent doesn't forget which track it was 127 | const [recentNonZeroParentTrackIdx, setRecentNonZeroParentTrackIdx] = useState(1) 128 | if (parentTrackIdx !== 0 && recentNonZeroParentTrackIdx !== parentTrackIdx) { 129 | setRecentNonZeroParentTrackIdx(parentTrackIdx) 130 | } 131 | 132 | return 133 | { 137 | if (state === newState) return 138 | if (newState === "auto") { 139 | onChange(255, 0) 140 | } else if (newState === "manual") { 141 | onChange(1, 0) 142 | } else if (newState === "parent") { 143 | onChange(polyphonicIdx, Math.min(recentNonZeroParentTrackIdx, maxParentTrackIdx)) 144 | } 145 | }} 146 | > 147 | Automatic 148 | Manual 149 | Conditional takeover 150 | 151 | {state === "manual" ? onChange(voiceCountToPolyphonicIdx(voiceCount), 0)} 158 | /> : <>>} 159 | {state === "parent" ? onChange(polyphonicIdx, parentTrackIdx)} 167 | /> : <>>} 168 | 169 | } 170 | --------------------------------------------------------------------------------
29 | {this.state.error.toString()} 30 |
29 | An error occurred loading Mamar. If you think this is a bug, please report it. 30 |
32 | {errorMessage} 33 |
{loadError.message}
31 | 32 | Mamar is loading... 33 |
44 | Please enable JavaScript to use Mamar. 45 |
{row.id.toString(16).toUpperCase()}
41 | paper mario music editor 42 |
47 | Mamar is a digital audio workstation, MIDI editor, and music player for Paper Mario on Nintendo 64. 48 | 49 | Included is an emulator which provides identical playback capabilities to the game. 50 |
57 | With Mamar, you can: 58 |
.bgm
.midi
86 | Mamar is open-source and contributions are welcome! 87 |
89 | The source code is available on GitHub. 90 |
bgm_set_variation