This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
A C# port of Olivier Lapicque's Mod95 tracker player. SharpMod/ is the engine library (no UI, no audio backend); everything else is a front-end that pulls PCM out of it. Supported formats: MOD, S3M, XM, STM, 669. Target framework is net10.0 across all projects.
The library is described as a verbatim port and deliberately keeps the original C++ quirks (the unused instrument slot at index 0, both finetune and C5-speed stored per sample, goto-free but very imperative loops). Don't "modernize" engine code unless asked — behaviour fidelity is the point.
AGENTS.md covers the same ground for other agent tools and must be kept in sync. If you change one, change the other.
# Library + console player (fast, this is 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 tree: bin/Release/net10.0/publish/wwwroot/
# Tests (xUnit)
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 (Release/mods/ is a checked-in corpus of real modules)
dotnet run --project SharpMod.ConsolePlayer -- "Release/mods/CRONOLOG.S3M"All projects share OutputPath=..\Release\, so binaries from every front-end land side by side in Release/. Release/mods/ is the go-to real-world test corpus for all five formats, and is kept out of the [Rr]elease/ ignore by three anchored rules near the end of .gitignore (!/Release/, /Release/*, !/Release/mods/). The obvious one-liner — !**/Release/mods — is what used to be there and it silently does nothing, because git will not re-include anything inside an excluded directory. That left new mods invisible to git status while the already-tracked ones kept working on index inertia; don't "simplify" it back.
Building the whole solution (dotnet build SharpModPlayer.sln) also builds the Eto.Forms GUI heads, including the macOS one — 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 declares InternalsVisibleTo("SharpModConsolePlayer.Tests"), but the test assembly is actually named SharpMod.ConsolePlayer.Tests (dot). ChannelRenderTests therefore fails with CS0122: 'Channel' is inaccessible. Fix the InternalsVisibleTo value before relying on dotnet test.
This is one instance of a wider drift: the project directories use dots (SharpMod.ConsolePlayer), the C# namespaces do not (SharpModConsolePlayer, SharpModConsolePlayer.Renderer, SharpModConsolePlayer.Tests). .vscode/tasks.json, .vscode/launch.json, and the build section of AGENTS.md all still point at the old dotless paths (SharpModConsolePlayer/SharpModConsolePlayer.csproj) and are stale.
SoundFile (partial class, split across four files) is the whole engine:
- SharpMod/SharpMod.cs — ctors, format sniffing, and one
ParseXxxFile()per format - SharpMod/Mod95/Mod95Internals.cs —
Read()(the mixer) andReadNote()(the per-tick sequencer + effect dispatch) - SharpMod/Mod95/Mod95Data.cs —
Types,Effects,ModInstrument,ModChannel, wavetables,PreAmpTable - SharpMod/Mod95/Mod95Interop.cs — the public read-only surface (
Title,Row,Pattern,Channels,Position, …)
The key thing to internalize: the sequencer only understands two cell layouts, and every loader transcodes into one of them.
- MOD keeps its native 4-byte cells (
period-hi/inst-hi,period-lo,inst-lo/effect,param). - Everything else (S3M, XM, STM, 669) is normalized into the S3M-style 6-byte cell:
[mode, note, instrument, volume, command, param], wheremodebit0x20= note present,0x40= volume present,0x80= command present, and the low 5 bits carry the channel index.commandis a letter index ('A'→ 1). Note bytes are(octave << 4) | semitone;0xFE= note cut,0xFF= note off.
ReadNote() picks the stride with int inc = Type == Types.MOD ? 4 : 6; — the only format branch in the sequencer. CommandToString() in SharpMod/Helpers/Helpers.cs has the matching pair of branches for display.
Consequences worth knowing before touching a loader:
- Patterns are always 64 rows —
Row = (Row + 1) & 0x3Fis hardcoded. Formats with shorter patterns are padded; effects that would land beyond row 63 are dropped. - The transcoders live in
SharpMod.cs:EncodeSTMCell,EncodeXMCell, inline 669 encoding, andInjectPatternEffect(used to synthesize per-pattern speed/break commands that 669 stores in its header rather than in cells). - Format quirks are absorbed at transcode time by mapping to S3M effect letters — see the
effLettertable inParseC669Filefor the 669→S3M mapping and why slides are re-emitted every row.
The SoundFile ctor sniffs magic bytes in a specific nested order: MOD tags at 0x438 (M.K., FLT4, 4CHN, 16CH, …), then 0x2C for SCRM (S3M), then offset 0 for "Extended Module: " (XM), then STM header validation, then the XXXX-as-S3M fallback. STM must be checked before the XXXX fallback because ST2 fills its reserved field with 0x58 ('X'), which collides. 669 is detected by the if/JN prefix. Falling through leaves ActiveSamples = 15 (15-instrument legacy MOD).
Every front-end drives the same loop: open the backend with the format passed to the 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. The reference pseudocode is in README.md; the three real implementations are:
| Front-end | Backend | Where the loop lives |
|---|---|---|
| SharpMod.ConsolePlayer | OpenAL via OpenTK | OpenAlStreamPlayer.cs — keeps ~3 buffers queued; the AL context/source are reused across track switches to avoid handle exhaustion |
| SharpMod.Wasm | Web Audio AudioWorklet |
main.js pump()/pumpChunk() — a setInterval on the main thread keeps TARGET_LEAD_SEC (0.25s) of audio in the worklet's ring buffer |
| SharpMod.PlayerGUI | OpenAL via OpenTK | Eto.Forms shared MainForm + per-platform heads (.Wpf, .Gtk, .Mac) |
The engine is not thread-safe, and both the console and WASM players exploit that: the audio loop owns mutation while a separate render loop reads sf.Row, sf.Channels[], sf.Pos etc. 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 entire JS↔.NET surface: [JSExport] statics over a single static SoundFile? sf. Because JS interop calls are expensive per-call, per-frame data is packed into flat arrays with documented layouts rather than exposed as objects — e.g. GetChannelStates() returns 6 ints per channel, GetInstrumentMeta() returns 6 ints, GetWaveformEnvelope() returns interleaved min/max pairs, and GetPatternData() returns all 64 rows as one newline-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.
The one exception to the single-sf rule is ProbeMetadata(byte[]), which parses a throwaway second SoundFile so the demo-track browser can describe a module without disturbing playback — safe because the engine holds no mutable static state. It is also the one export that returns a U+001F-separated record rather than a packed array, since it is called once per track rather than 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, audio pump, and the rAF loop; view-patterns.js and view-samples.js are the two views, each exposing init* / render* / reset*. loadedToken is bumped on every successful Load() so views know to drop their caches. track-picker.js is the demo-track browser — a custom listbox (not a <select>, which cannot render per-row stats) that lists Release/mods via the GitHub contents API and lazily probes each module as its row scrolls into view. Probes are capped at PROBE_CONCURRENCY and cached; parsing the largest module in the corpus blocks the main thread for ~110 ms, comfortably inside the pump's 0.25 s lead, so probing while playing does not underrun.
Two integration details that are easy to break: the picker is a button + popup rather than a form control, so main.js's global shortcut handler has to consult isTrackPickerBusy() — its early-out for HTMLInputElement/HTMLSelectElement does not 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 must still 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 and copied over index.html in the publish output. Consequences:
- Never post-process
$(PublishDir)wwwroot/index.html. A target that rewrites itAfterTargets="Publish"leaves the destination newer than the SDK's generated source, so subsequent publishes skip the copy and shipindex.htmlwith empty placeholders — no import map. The literal/_framework/dotnet.jsthen 404s; the server answers with its HTML error page, and Firefox reportsNS_ERROR_CORRUPTED_CONTENT(an HTML body rejected as a module script). This exact bug shipped once; see the2ff8040rollback. - Because the breakage is timestamp-driven it is sticky: deleting the published
index.htmlalone does not fix it. Clearobj/Releaseandbin/Releaseand republish. - Verify before deploying — the published
index.htmlmust contain a populated<script type="importmap">:Select-String -Path SharpMod.Wasm/bin/Release/net10.0/publish/wwwroot/index.html -Pattern 'importmap','dotnet\.'
- Don't "fix" a runtime-load failure by hand-rolling a loader that probes for
dotnet.jscandidates. Theid="webassembly"attribute is removed by the substitution, so queryinglink#webassemblyfinds 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.jsreally 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 switch. Components (Channel, Samples, Info, SongProgress, Dialog) are statics that position the cursor themselves and cache aggressively — Samples.cs invalidates only on song / terminal-size / metadata-visibility / scroll-offset change, and does delta repaints of just the columns whose cursor moved.
Rendering uses PrettyConsole 6.x. Invoke the pretty-console-expert skill before doing any styling, input, live-region, or OutputPipe work — the v6 API differs substantially from earlier versions and from Spectre.Console.
- 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 live in
SharpMod/Helpers/<Fmt>Tools.csas static classes holding[StructLayout(LayoutKind.Sequential)]header structs plusIsValidHeader()/ effect-conversion helpers. Loader methods areParseXxxFile()onSoundFileand stay inSharpMod.cs. - Mixing is fixed-point:
MOD_PRECISION = 10,MOD_FRACMASK = 1023. Sample positions areQ22.10. - Per-mix attenuation follows OpenMPT's
PreAmpTablecurve (indexed bychannels >> 1), not a linear divide — 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. - Text decoding goes through
LegacyEncoding.Cp437(Helpers/ExtensionMethods.cs, hence theSystem.Text.Encoding.CodePagespackage reference) — tracker metadata is CP437, not UTF-8. - Comments in engine code exist to explain magic offsets, format quirks, and deliberate deviations from OpenMPT/FT2 semantics. When you fix a compatibility bug, leave a note saying which tracker's behaviour you matched — that's the established pattern and it's the only record of why the code looks wrong.
AssemblyVersion/FileVersionare hand-bumped date-stamps (e.g.2026.6.22.4) in each.csproj.
SharpMod.ConsolePlayer'ssupportedExtensionsin Program.cs:6 is[".mod", ".stm", ".s3m", ".xm"]— it's missing.669, so directory/glob playlist expansion silently skips 669 files even though the loader and the README both support them. (Naming an individual 669 file on the command line still works; only directory/glob expansion filters.) The WASM file input'sacceptattribute does list.669.SoundFiledisposes the input stream after parsing when it owns it (file-path andbyte[]ctors); theStreamctor does not. Everything is parsed eagerly into memory, so there's no streaming-from-disk playback.- Restart
dotnet runafter editing anything underwwwroot/— the dev server does not hot-reload it.index.htmlis served from a build-time copy, so edits simply don't appear. JS is worse than stale: the static-web-assets pipeline fingerprints modules and puts their build-time SHA-256 in the import map'sintegrity, so an edited file is blocked —Failed to find a valid digest in the 'integrity' attribute— the module import fails, and the app hangs on "Loading .NET runtime…" with nothing else logged. (CSS does appear to be served live, but restarting is the only reliable rule.) - 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 a comment in that file:brew install openal-softand retarget the twoosxOpenAL entries at the resultinglibopenal.dylib. - Original Source Code/ holds Lapicque's
mod95srcC++ and anopenmpt-mastercheckout. Both are the reference of record — the engine's compatibility fixes are frequently justified by pointing at OpenMPT'sSndmix.cpp/effTrans, so check there when behaviour looks wrong.