| name | SharpModPlayer |
|---|---|
| description | Cross-platform .NET MOD/tracker music player library with terminal, desktop GUI and WebAssembly front-ends |
A cross-platform C# port of Olivier Lapicque's Mod95 player, supporting MOD, S3M, XM, STM and 669 tracker formats. SharpMod/ is the engine library — no UI, no audio backend. Everything else is a front-end that pulls PCM out of it.
The library is a deliberately verbatim port and keeps the original C++ quirks (the unused instrument slot at index 0, both finetune and C5-speed stored per sample, very imperative mixing loops). Don't modernize engine code unless asked — behavioural fidelity is the point.
This file and CLAUDE.md cover the same ground and must be kept in sync. If you change one, change the other.
SharpMod/ # Core library: format parsing & playback engine
SharpMod.cs # Ctors + format sniffing + one ParseXxxFile() per format
Helpers/
Helpers.cs # CommandToString() (pattern-cell → display text)
ExtensionMethods.cs # LegacyEncoding.Cp437
S3MTools.cs, XMTools.cs, STMTools.cs, C669Tools.cs # Header structs + validation/effect maps
Mod95/
Mod95Internals.cs # Read() (mixer) + ReadNote() (sequencer / effect dispatch)
Mod95Data.cs # Types, Effects, ModInstrument, ModChannel, wavetables, PreAmpTable
Mod95Interop.cs # Public read-only surface (Title, Row, Pattern, Channels, Position…)
SharpMod.ConsolePlayer/ # Terminal UI: PrettyConsole 6.x + OpenAL (OpenTK)
Program.cs # Playlist expansion; spawns render loop, drives audio loop
Cli.cs # CLI argument parsing
OpenAlStreamPlayer.cs # OpenAL context & buffer queue management
WavExporter.cs # --export offline render
Renderer/ # ConsoleRenderer.cs (loop + input) + Channel, Samples,
# Info, SongProgress, Dialog components
SharpMod.PlayerGUI/ # Desktop GUI: Eto.Forms + OpenAL
SharpMod.PlayerGUI/ # shared MainForm + Renderer
SharpMod.PlayerGUI.{Wpf,Gtk,Mac}/ # per-platform launchers
SharpMod.Wasm/ # Browser player: [JSExport] bridge + Web Audio
SharpModInterop.cs # The entire JS↔.NET surface
wwwroot/main.js # runtime bootstrap, audio pump, rAF loop
wwwroot/mod-processor.js # AudioWorklet ring buffer
wwwroot/view-patterns.js, view-samples.js
wwwroot/track-picker.js # demo-track browser (GitHub listing + lazy probes)
SharpMod.ConsolePlayer.Tests/ # xUnit: ChannelRenderTests, XMLoaderTests,
# STMLoaderTests, EffectHandlerTests
Original Source Code/ # mod95src (Lapicque's C++) + openmpt-master, for reference
Release/ # Shared OutputPath for every project; Release/mods/ is a
# checked-in corpus of real modules (see the .gitignore note
# under Gotchas -- the re-include is easy to break)
# Library + console player — the usual inner loop
dotnet build SharpMod.ConsolePlayer/SharpMod.ConsolePlayer.csproj
# Browser front-end
dotnet build SharpMod.Wasm/SharpMod.Wasm.csproj
dotnet run --project SharpMod.Wasm # dev server
dotnet publish SharpMod.Wasm/SharpMod.Wasm.csproj -c Release # deployable: bin/Release/net10.0/publish/wwwroot/
# Tests
dotnet test SharpMod.ConsolePlayer.Tests
dotnet test SharpMod.ConsolePlayer.Tests --filter "FullyQualifiedName~XMLoaderTests"
dotnet test SharpMod.ConsolePlayer.Tests --filter "DisplayName~RestartPos_OutOfRange_ClampsToZero"
# Play something
dotnet run --project SharpMod.ConsolePlayer -- "Release/mods/CRONOLOG.S3M"Target framework is net10.0 everywhere. All projects share OutputPath=..\Release\, so binaries land side by side in Release/.
Building the whole solution also builds the Eto.Forms macOS head, which emits a harmless "Can only create universal binary on macOS" warning on Windows. Prefer building the specific project you're working on.
SharpMod.ConsolePlayer.csproj:25 declares InternalsVisibleTo("SharpModConsolePlayer.Tests"), but the test assembly is named SharpMod.ConsolePlayer.Tests (with a dot). ChannelRenderTests.cs:6 therefore fails with CS0122: 'Channel' is inaccessible, and dotnet test cannot run at all. Fix that InternalsVisibleTo value before relying on the suite.
This is one symptom of a wider drift: project directories use dots (SharpMod.ConsolePlayer), C# namespaces do not (SharpModConsolePlayer, SharpModConsolePlayer.Renderer, SharpModConsolePlayer.Tests). .vscode/tasks.json and .vscode/launch.json still reference the pre-rename paths SharpModConsolePlayer/SharpModConsolePlayer.csproj, which no longer exist — the build, publish and watch tasks and the debug launch config are all broken.
This is the single most important thing to internalize. The sequencer understands only two cell layouts, and every loader transcodes into one of them:
- MOD keeps its native 4-byte cell:
[period-hi | inst-hi, period-lo, inst-lo | effect, param]. - S3M, XM, STM and 669 are all normalized into the S3M-style 6-byte cell:
[mode, note, instrument, volume, command, parameter]- mode —
0x20= note present,0x40= volume present,0x80= command present; low 5 bits carry the channel index.mode == 0means "empty cell, skip". - note —
(octave << 4) | semitone;0xFE= note cut,0xFF= note off. - instrument — sample index (
0= none). - command — effect letter as an index,
'A'→ 1. - parameter — often nibble-encoded (e.g.
0x47= up 4, down 7).
- mode —
ReadNote() picks the stride with int inc = Type == Types.MOD ? 4 : 6; — the only format branch in the sequencer. CommandToString() in Helpers/Helpers.cs has the matching pair of branches for display.
Consequences before you touch a loader:
- Patterns are always 64 rows —
Row = (Row + 1) & 0x3Fis hardcoded. Shorter patterns are padded; effects that would land past row 63 are dropped. - Transcoders live in
SharpMod.cs:EncodeSTMCell,EncodeXMCell, inline 669 encoding, andInjectPatternEffect(synthesizes per-pattern speed/break commands that 669 stores in its header rather than in cells). - Format quirks are absorbed at transcode time by mapping onto S3M effect letters — see the
effLettertable inParseC669Filefor the 669→S3M mapping and why slides are re-emitted every row.
SoundFile's ctor sniffs magic bytes in a specific nested order:
0x438— MOD tags (M.K.,FLT4–FLT9,4CHN,16CH, …), which also sets the channel count0x2C—SCRM→ S3M- offset
0—"Extended Module: "→ XM - STM header validation
XXXX→ S3M fallbackif/JNprefix → 669
Step 4 must precede step 5: ST2 stuffs its reserved field with 0x58 ('X'), which collides with the XXXX fallback. Falling through everything leaves ActiveSamples = 15 (legacy 15-instrument MOD).
Every front-end runs the same loop: open the backend with the exact format passed to the SoundFile ctor, then repeatedly call sf.Read(buffer, length) and hand the bytes to the device, using the backend's queue depth as back-pressure rather than a fixed sleep. Read() returning 0 means end-of-song when Loop == false. Reference pseudocode is in README.md.
| Front-end | Backend | Where the loop lives |
|---|---|---|
| ConsolePlayer | OpenAL (OpenTK) | OpenAlStreamPlayer.cs — keeps ~3 buffers queued; the AL context/source are reused across track switches to avoid handle exhaustion and keep the second track audible |
| Wasm | Web Audio AudioWorklet |
main.js pump()/pumpChunk() — a 20 ms setInterval keeps TARGET_LEAD_SEC (0.25 s) of audio in the worklet's ring buffer |
| PlayerGUI | OpenAL (OpenTK) | Eto.Forms shared MainForm + per-platform heads |
The engine is not thread-safe, by design. The console and WASM players both exploit this: the audio loop owns mutation while a separate render loop reads sf.Row, sf.Channels[], sf.Pos every frame with no locking. Torn reads are tolerated as visual noise. Keep it that way — don't add locks to the mixer's hot path.
SharpModInterop.cs is the whole JS↔.NET surface: [JSExport] statics over a single static SoundFile? sf. Interop calls are expensive per-call, so per-frame data is packed into flat arrays with documented layouts instead of objects:
GetChannelStates()→ 6 ints per channel:[muted, instrumentIndex, currentVolume, pan, isStereoSample, isActive]GetInstrumentMeta()→[length, volume, is16, isStereo, loopStart, loopEnd]GetWaveformEnvelope(i, width)→ interleaved[min, max]pairsGetPatternData(i)→ all 64 rows as one\n-joined string of fixed-width 14-char cells
When adding a per-frame field, extend an existing packed array and update its layout comment on both sides. Don't add a new round-trip.
ProbeMetadata(byte[]) is the one export that breaks both rules, deliberately: it parses a throwaway second SoundFile so the demo-track browser can describe a module without disturbing playback (safe — the engine holds no mutable static state), and it returns a U+001F-separated record instead of a packed array because it runs once per track, not per frame. Its field order is mirrored by PROBE_FIELDS in track-picker.js; change one and you must change the other.
JS side: main.js owns runtime bootstrap, the audio pump and the rAF loop; view-patterns.js / view-samples.js each expose init* / render* / reset*. loadedToken is bumped on every successful Load() so views know to drop caches. track-picker.js is the demo-track browser: a custom listbox (a <select> cannot render per-row stats) that lists Release/mods through the GitHub contents API and lazily probes each module as its row scrolls into view, capped at PROBE_CONCURRENCY and cached. Parsing the corpus's largest module blocks the main thread ~110 ms — inside the pump's 0.25 s lead, so probing during playback doesn't underrun.
Two things that are easy to break: the picker is a button + popup, not a form control, so the global shortcut handler in main.js must consult isTrackPickerBusy() (its HTMLInputElement/HTMLSelectElement early-out doesn't cover it); and Space is deliberately not handled by the picker button — only Enter and ArrowDown open it — because choosing a track leaves that button focused and Space still has to mean play/pause.
main.js starts with a plain import { dotnet } from './_framework/dotnet.js';, but a published build contains no _framework/dotnet.js — only fingerprinted files like dotnet.a2smcxlaab.js. That bare specifier resolves only because <OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders> makes the SDK rewrite two placeholders in index.html at publish time:
<link rel="preload" id="webassembly" /> <!-- becomes <link href="_framework/dotnet.<hash>.js" rel="preload" ...> -->
<script type="importmap"></script> <!-- becomes the generated import map -->The substituted copy is generated into obj/<cfg>/net10.0/staticwebassets/htmlassetplaceholders/publish/<hash>.html, then copied over index.html in the publish output.
- Never post-process
$(PublishDir)wwwroot/index.html. A target that rewrites itAfterTargets="Publish"leaves the destination newer than the SDK's generated source, so later publishes skip the copy and shipindex.htmlwith empty placeholders — no import map at all. The literal/_framework/dotnet.jsthen 404s, the server answers with its HTML error page, and Firefox reportsNS_ERROR_CORRUPTED_CONTENT(HTML body rejected as a module script). This exact bug shipped once and was undone by the2ff8040rollback. - The breakage is timestamp-driven and therefore sticky: deleting the published
index.htmlalone does not fix it. Clearobj/Releaseandbin/Release, then republish. - Verify before deploying — the published
index.htmlmust contain a populated import map:Select-String -Path SharpMod.Wasm/bin/Release/net10.0/publish/wwwroot/index.html -Pattern 'importmap','dotnet\.'
- Don't work around a runtime-load failure by hand-rolling a loader that probes for
dotnet.jscandidates. The substitution removes theid="webassembly"attribute, soquerySelector('link#webassembly')finds nothing in a published build. Fix the import map instead. - Local
dotnet runcannot reproduce any of this: the dev server serves the unfingerprinted build, where_framework/dotnet.jsgenuinely exists.
Renderer/ConsoleRenderer.cs runs RenderLoop on its own task, polling a Func<SoundFile?> so track switches are picked up without restarting the loop. HandleInput owns all keyboard handling and the ViewMode (Patterns / Samples) switch. Components are statics that position the cursor themselves and cache aggressively — Samples.cs invalidates only on song / terminal-size / metadata-visibility / scroll-offset change, and delta-repaints just the columns whose cursor moved.
Rendering uses PrettyConsole 6.x. Invoke the pretty-console-expert skill before any styling, input, live-region or OutputPipe work — the v6 API differs substantially from earlier versions and from Spectre.Console. Keep durable UI (patterns, samples) on OutputPipe.Out and transient spinners on OutputPipe.Error.
- Add
SharpMod/Helpers/XyzTools.cs: a static class with[StructLayout(LayoutKind.Sequential)]header structs plusIsValidHeader()and any effect-conversion table. - Add
ParseXyzFile()toSharpMod.cs(loaders live onSoundFile, not in the Tools class). - Populate
instruments[],patterns[][],order[]andchannels[], transcoding cells into the 6-byte S3M layout and padding to 64 rows per pattern. - Add a
Typesenum member and wire detection into the ctor's sniffing chain — mind the ordering constraints above. - Add loader tests in
SharpMod.ConsolePlayer.Tests/that build a synthetic file in memory and assert instrument metadata, loop points and cell encoding. - Add the extension to
supportedExtensionsin Program.cs and to the WASM file input'sacceptlist.
- Add a file under
Renderer/with a staticRender(SoundFile sf). - Position with
Console.SetCursorPosition(); style withConsole.WriteInterpolated()andColortokens. - Cache anything that doesn't change every frame, and invalidate on song / terminal-size / option changes — follow the pattern in
Samples.cs.
Update the parsing logic and the Cli record in Cli.cs, extend PrintUsage(), thread the value through Program, and update the options table in SharpMod.ConsolePlayer/README.md.
- Inspect queue state with
AL.GetSource(alSrc, ALGetSourcei.BuffersQueued, out int q). - Confirm
SoundFile.Read()returns non-zero before end-of-song, and that the backend was opened with the same rate/bit-depth/channel count passed to the ctor. - Check the queue-depth target and the silent prime buffer.
- On macOS see the OpenAL note under Gotchas.
- Formatting is enforced by .editorconfig and is non-default C#: braces on the same line, no space after control-flow keywords (
if(x),for(...),while(...)),else/catch/finallyon the same line as the closing brace, single-line statements and blocks preserved. Match it — a reformat pass on engine code produces enormous noise diffs. - Format helpers:
SharpMod/Helpers/<Fmt>Tools.cs; loader methods:ParseXxxFile()onSoundFileinSharpMod.cs. - Mixing is fixed-point:
MOD_PRECISION = 10,MOD_FRACMASK = 1023; sample positions are Q22.10. - Per-mix attenuation follows OpenMPT's
PreAmpTablecurve (indexed bychannels >> 1), calibrated so 4 channels reproduces Mod95's original divisor of 32. Don't replace it with a linear divider — that over-attenuates dense many-channel songs and clamping instead wraps sums through the byte cast as crackling. - Tracker text is CP437, not UTF-8 — decode via
LegacyEncoding.Cp437(Helpers/ExtensionMethods.cs), which is whySystem.Text.Encoding.CodePagesis referenced. - Comments in engine code exist to explain magic offsets, format quirks and deliberate deviations from OpenMPT/FT2 semantics. When you fix a compatibility bug, note which tracker's behaviour you matched — that's the established pattern and often the only record of why the code looks wrong.
AssemblyVersion/FileVersionare hand-bumped date-stamps (e.g.2026.6.22.4) in each.csproj.- Run the tests before committing changes to loaders or effect handlers (once the
InternalsVisibleTofix above is in place).
supportedExtensionsin Program.cs:6 is[".mod", ".stm", ".s3m", ".xm"]— missing.669, so directory and glob playlist expansion silently skips 669 files the loader supports and the README advertises. Naming an individual 669 file still works.SoundFileparses everything eagerly into memory; there is no streaming-from-disk playback. It disposes the input stream when it owns it (thestringandbyte[]ctors); theStreamctor does not.- Restart
dotnet runafter editing anything underwwwroot/; the dev server does not hot-reload it.index.htmlis served from a build-time copy, so edits just don't show up. JS fails harder than that: the static-web-assets pipeline fingerprints modules and writes their build-time SHA-256 into the import map'sintegrity, so the browser blocks an edited file —Failed to find a valid digest in the 'integrity' attribute— the import fails and the app hangs on "Loading .NET runtime…" with nothing else logged. (CSS does seem to be served live, but restarting is the only rule worth remembering.) - macOS OpenAL: OpenTK.dll.config still maps
openal32.dllto Apple's deprecatedOpenAL.framework, which SIGSEGVs insidealGetSourceiunder rapid queue/unqueue churn (e.g. spamming Home/End to switch tracks). The fix is manual and documented in that file:brew install openal-softand retarget the twoosxOpenAL entries at the resultinglibopenal.dylib. Release/mods/is the go-to real-world corpus covering all five formats. It escapes the[Rr]elease/ignore only via three anchored rules near the end of.gitignore(!/Release/,/Release/*,!/Release/mods/). The obvious!**/Release/modsone-liner that used to be there does nothing — git cannot re-include a path inside an excluded directory — so new mods were silently invisible togit statuswhile the already-tracked ones kept working purely because.gitignoredoesn't apply to files in the index. Don't collapse it back.
- Mod95 / OpenMPT legacy software — the port's heritage
- PrettyConsole — TUI toolkit
- OpenTK — OpenAL bindings
- Eto.Forms — desktop GUI toolkit
- Live WASM demo: sharpmod.djxavi.com