Skip to content

Latest commit

 

History

History
135 lines (88 loc) · 16.7 KB

File metadata and controls

135 lines (88 loc) · 16.7 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

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.

Build / test / run

# 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.

Known-broken: the test project does not compile

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.

Architecture

One engine, two internal pattern representations

SoundFile (partial class, split across four files) is the whole engine:

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], where mode bit 0x20 = note present, 0x40 = volume present, 0x80 = command present, and the low 5 bits carry the channel index. command is 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 rowsRow = (Row + 1) & 0x3F is 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, and InjectPatternEffect (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 effLetter table in ParseC669File for the 669→S3M mapping and why slides are re-emitted every row.

Format detection order is load-bearing

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).

Consumer contract

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.

WASM bridge

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.

WASM publishing: the importmap is load-bearing — don't touch index.html

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 it AfterTargets="Publish" leaves the destination newer than the SDK's generated source, so subsequent publishes skip the copy and ship index.html with empty placeholders — no import map. The literal /_framework/dotnet.js then 404s; the server answers with its HTML error page, and Firefox reports NS_ERROR_CORRUPTED_CONTENT (an HTML body rejected as a module script). This exact bug shipped once; see the 2ff8040 rollback.
  • Because the breakage is timestamp-driven it is sticky: deleting the published index.html alone does not fix it. Clear obj/Release and bin/Release and republish.
  • Verify before deploying — the published index.html must 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.js candidates. The id="webassembly" attribute is removed by the substitution, so querying link#webassembly finds nothing in a published build. Fix the import map instead.
  • Local dotnet run cannot reproduce any of this: the dev server serves the unfingerprinted build where _framework/dotnet.js really exists.

Console renderer

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.

Conventions

  • 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/finally on 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.cs as static classes holding [StructLayout(LayoutKind.Sequential)] header structs plus IsValidHeader() / effect-conversion helpers. Loader methods are ParseXxxFile() on SoundFile and stay in SharpMod.cs.
  • Mixing is fixed-point: MOD_PRECISION = 10, MOD_FRACMASK = 1023. Sample positions are Q22.10.
  • Per-mix attenuation follows OpenMPT's PreAmpTable curve (indexed by channels >> 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 the System.Text.Encoding.CodePages package 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/FileVersion are hand-bumped date-stamps (e.g. 2026.6.22.4) in each .csproj.

Gotchas

  • SharpMod.ConsolePlayer's supportedExtensions in 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's accept attribute does list .669.
  • SoundFile disposes the input stream after parsing when it owns it (file-path and byte[] ctors); the Stream ctor does not. Everything is parsed eagerly into memory, so there's no streaming-from-disk playback.
  • Restart dotnet run after editing anything under wwwroot/ — the dev server does not hot-reload it. index.html is 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's integrity, so an edited file is blockedFailed 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.dll to Apple's deprecated OpenAL.framework, which SIGSEGVs inside alGetSourcei under 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-soft and retarget the two osx OpenAL entries at the resulting libopenal.dylib.
  • Original Source Code/ holds Lapicque's mod95src C++ and an openmpt-master checkout. Both are the reference of record — the engine's compatibility fixes are frequently justified by pointing at OpenMPT's Sndmix.cpp / effTrans, so check there when behaviour looks wrong.