Make RustRaptor the repository root and track its playable bundle
This commit is contained in:
1 parent
4b1edefeec
commit
8f459796b1
9860 files changed
+21084
-61019
No files matched your search
@@ -0,0 +1,36 @@
|
||||
# Iron Coast campaign music
|
||||
|
||||
**Coastal Steel** is an original 96-second, 140 BPM FM action score with six
|
||||
sections: approach, breakwater riff, offshore pursuit, stormwall bridge,
|
||||
citadel assault and the return to the coast. Fingered bass, muted power chords,
|
||||
an overdriven guitar melody, a contrasting lead and restrained strings sit
|
||||
over kick, snare, hats and occasional tom fills. The closing phrase resolves
|
||||
to the opening E-minor harmony for continuous mission playback.
|
||||
|
||||
The package is independently toggleable, uses mod API 1 and has no mod
|
||||
dependencies. Iron Coast missions depend on it and reference
|
||||
`iron_coast_music:iron_coast` through `music_track`. It uses the existing Rust
|
||||
MUS/GENMIDI FM music bus, so the game's music volume and mute controls apply.
|
||||
The original game instrument bank is required locally; it is not copied here.
|
||||
|
||||
The score, deterministic composition recipe and provenance are included in
|
||||
Git. No original-game melody, recording or instrument data is bundled.
|
||||
Regenerate the exact score with:
|
||||
|
||||
```sh
|
||||
python3 mods/iron_coast_music/build_music.py
|
||||
```
|
||||
|
||||
The engine services MUS events at 70 Hz. This score uses five melodic channels
|
||||
and standard percussion on channel 15, releases notes explicitly, and ends
|
||||
after 6,720 ticks. It stays within the checked package music limits. The
|
||||
looping flag is declared in `content.json`; neither a Rhai script nor an
|
||||
ordinary sound-effect command is needed for playback.
|
||||
|
||||
Validation uses the actual Rust decoder and locally installed original FM
|
||||
instrument bank. A 100-second stereo render is non-silent through the first
|
||||
loop, has no clipped samples, and mutes to exact digital silence. The recipe
|
||||
also checks paired note events and the 6,720-tick duration. Numeric results are
|
||||
in `validation.json`; human listening and normal-play sound balance remain
|
||||
part of wider campaign testing. The locally rendered preview remains outside
|
||||
Git because it uses the original instrument bank.
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Author Coastal Steel as a deterministic checked MUS score, without MIDI tools.
|
||||
|
||||
The Rust game's MUS service clock is 70 Hz. A quarter lasts 30 ticks: 140 BPM.
|
||||
56 four-beat bars give a 96-second loop. All musical phrases below are original
|
||||
authored note sequences; no original-game music is read by this recipe.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
BAR = 120
|
||||
END = 56 * BAR
|
||||
EVENTS = defaultdict(list)
|
||||
PROGRAMS = {0: 33, 1: 30, 2: 29, 3: 81, 4: 48}
|
||||
VOLUMES = {0: 86, 1: 72, 2: 85, 3: 58, 4: 42, 15: 83}
|
||||
PAN = {0: 64, 1: 32, 2: 64, 3: 100, 4: 64, 15: 64}
|
||||
SECTIONS = [
|
||||
{"name": "Approach", "first_bar": 0, "bars": 4},
|
||||
{"name": "Breakwater riff", "first_bar": 4, "bars": 12},
|
||||
{"name": "Offshore pursuit", "first_bar": 16, "bars": 12},
|
||||
{"name": "Stormwall bridge", "first_bar": 28, "bars": 8},
|
||||
{"name": "Citadel assault", "first_bar": 36, "bars": 16},
|
||||
{"name": "Return to coast", "first_bar": 52, "bars": 4},
|
||||
]
|
||||
|
||||
|
||||
def event(tick, priority, descriptor, payload):
|
||||
assert 0 <= tick <= END
|
||||
EVENTS[tick].append((priority, bytes([descriptor]) + bytes(payload)))
|
||||
|
||||
|
||||
def control(channel, number, value):
|
||||
event(0, 0, 0x40 | channel, [number, value])
|
||||
|
||||
|
||||
def note(channel, key, tick, duration, velocity):
|
||||
assert 0 <= key <= 127 and 0 < velocity <= 127 and duration > 0
|
||||
end = min(tick + duration, END - 1)
|
||||
event(tick, 2, 0x10 | channel, [key | 0x80, velocity])
|
||||
event(end, 1, channel, [key])
|
||||
|
||||
|
||||
def variable_length(value):
|
||||
pieces = [value & 127]
|
||||
value >>= 7
|
||||
while value:
|
||||
pieces.append(value & 127)
|
||||
value >>= 7
|
||||
return bytes(v | (0x80 if i else 0) for i, v in reversed(list(enumerate(pieces))))
|
||||
|
||||
|
||||
for channel, program in PROGRAMS.items():
|
||||
control(channel, 0, program)
|
||||
for channel in VOLUMES:
|
||||
control(channel, 3, VOLUMES[channel])
|
||||
control(channel, 4, PAN[channel])
|
||||
|
||||
# E minor riff and its G-major/Am contrast. Each cell describes one eighth
|
||||
# note per slot; None leaves a melodic breath. The refrain has a clear call
|
||||
# (E-G-B-A) and downward answer (G-F#-E-D), with a four-bar tonic resolution.
|
||||
MELODY_A = [
|
||||
[76, None, 79, 83, 81, 79, 78, 76],
|
||||
[74, 76, None, 79, 78, None, 76, None],
|
||||
[72, None, 76, 79, 83, 81, 79, 76],
|
||||
[74, 78, 81, None, 79, 78, 76, None],
|
||||
]
|
||||
MELODY_B = [
|
||||
[79, 83, 86, None, 83, 81, 79, None],
|
||||
[78, None, 81, 85, 83, 81, 78, 74],
|
||||
[76, 81, None, 84, 83, 81, 79, 76],
|
||||
[76, 79, 84, None, 83, 79, 76, None],
|
||||
]
|
||||
CADENCE = [
|
||||
[81, None, 78, 74, 78, 79, 81, None],
|
||||
[79, 76, 72, None, 76, None, 79, None],
|
||||
[78, 75, 71, None, 75, 78, 83, None],
|
||||
[83, 81, 79, 78, 76, None, None, None],
|
||||
]
|
||||
|
||||
|
||||
def harmony(bar):
|
||||
if bar < 4:
|
||||
return [40, 40, 36, 38][bar]
|
||||
if bar < 16:
|
||||
return [40, 40, 36, 38][(bar - 4) % 4]
|
||||
if bar < 28:
|
||||
return [43, 38, 45, 36][(bar - 16) % 4]
|
||||
if bar < 36:
|
||||
return [40, 35, 36, 38][(bar - 28) % 4]
|
||||
if bar < 52:
|
||||
return [40, 40, 36, 38][(bar - 36) % 4]
|
||||
return [38, 36, 35, 40][bar - 52]
|
||||
|
||||
|
||||
for bar in range(56):
|
||||
tick = bar * BAR
|
||||
root = harmony(bar)
|
||||
intro = bar < 4
|
||||
bridge = 28 <= bar < 36
|
||||
climax = 36 <= bar < 52
|
||||
turnaround = bar >= 52
|
||||
|
||||
# Fingered bass alternates the root and fifth, leaving space for the drums.
|
||||
for slot, interval in enumerate([0, 0, 7, 0, 12, 7, 0, 7]):
|
||||
if bridge and slot % 2:
|
||||
continue
|
||||
note(0, root + interval, tick + slot * 15,
|
||||
24 if bridge else 11, 97 if slot % 2 == 0 else 78)
|
||||
|
||||
# Palm-muted FM power-chord pulses. Intro/bridge leave an open half-bar;
|
||||
# the assault adds an upper octave accent without dense voice stealing.
|
||||
slots = [0, 4, 6] if intro else [0, 2, 4, 6] if bridge else list(range(8))
|
||||
for slot in slots:
|
||||
start = tick + slot * 15
|
||||
length = 24 if bridge else 10
|
||||
velocity = 82 if slot % 2 == 0 else 63
|
||||
note(1, root + 12, start, length, velocity)
|
||||
note(1, root + 19, start, length, velocity - 9)
|
||||
if climax and slot in [0, 4]:
|
||||
note(1, root + 24, start, 9, 54)
|
||||
|
||||
# A quiet sustained interval opens up the bridge and supports phrase starts.
|
||||
if bridge or bar % 4 == 0:
|
||||
note(4, root + 24, tick, BAR - 6, 64 if bridge else 47)
|
||||
note(4, root + 31, tick, BAR - 6, 49 if bridge else 37)
|
||||
|
||||
melody = None
|
||||
if 4 <= bar < 16:
|
||||
melody = MELODY_A[(bar - 4) % 4]
|
||||
elif 16 <= bar < 28:
|
||||
melody = MELODY_B[(bar - 16) % 4]
|
||||
elif climax:
|
||||
melody = MELODY_A[(bar - 36) % 4]
|
||||
elif turnaround:
|
||||
melody = CADENCE[bar - 52]
|
||||
if melody:
|
||||
for slot, key in enumerate(melody):
|
||||
if key is not None:
|
||||
next_rest = slot == 7 or melody[slot + 1] is None
|
||||
length = min(25 if next_rest else 12, BAR - slot * 15 - 2)
|
||||
note(2, key, tick + slot * 15, length,
|
||||
95 if slot in [0, 4] else 83)
|
||||
if climax and bar % 4 == 3:
|
||||
# A short response joins the refrain at the end of the phrase.
|
||||
for slot, key in [(4, 83), (5, 81), (6, 79), (7, 78)]:
|
||||
note(3, key, tick + slot * 15 + 3, 10, 71)
|
||||
elif bridge:
|
||||
for slot, key in enumerate([root + 24, root + 31, root + 36, root + 31]):
|
||||
note(3, key, tick + slot * 30, 23, 78)
|
||||
|
||||
# Standard percussion on MUS channel 15. Repeating closed hats are softer
|
||||
# than snare/kick; phrase ends use a restrained tom fill, never a sample loop.
|
||||
hats = range(0, 16, 2) if intro or bridge else range(16)
|
||||
for slot in hats:
|
||||
start = tick + (slot * 15) // 2
|
||||
key = 46 if climax and slot == 14 else 42
|
||||
note(15, key, start, 5, 64 if slot % 4 == 0 else 40 if slot % 2 else 49)
|
||||
kick_slots = [0, 60] if intro or bridge else [0, 45, 60, 105]
|
||||
for offset in kick_slots:
|
||||
note(15, 36, tick + offset, 8, 111 if offset in [0, 60] else 83)
|
||||
for offset in [30, 90]:
|
||||
note(15, 38, tick + offset, 8, 104 if not bridge else 85)
|
||||
if bar in [4, 16, 28, 36, 52]:
|
||||
note(15, 49, tick, 18, 87)
|
||||
if bar in [3, 15, 27, 35, 43, 51, 54]:
|
||||
for offset, key in [(90, 47), (97, 47), (105, 45), (112, 43)]:
|
||||
note(15, key, tick + offset, 6, 85)
|
||||
|
||||
# Release every voice at the loop boundary. The final melody resolves to E;
|
||||
# the next approach begins on E without a hanging voice or an abrupt chord flip.
|
||||
for channel in VOLUMES:
|
||||
event(END - 1, 3, 0x30 | channel, [11])
|
||||
event(END, 4, 0x60, [])
|
||||
|
||||
ticks = sorted(EVENTS)
|
||||
stream = bytearray()
|
||||
for index, tick in enumerate(ticks):
|
||||
group = sorted(EVENTS[tick], key=lambda e: e[0])
|
||||
for position, (_, raw) in enumerate(group):
|
||||
last = position == len(group) - 1
|
||||
descriptor = raw[0] | (0x80 if last and tick < END else 0)
|
||||
stream.extend(bytes([descriptor]) + raw[1:])
|
||||
if tick < END:
|
||||
stream.extend(variable_length(ticks[index + 1] - tick))
|
||||
|
||||
# MUS preload tables use 100 + percussion key, distinct from the synthesizer's
|
||||
# internal GENMIDI bank indexing. Verified against all eleven local original
|
||||
# scores containing percussion; the recipe never reads their melody data.
|
||||
instruments = sorted(set(PROGRAMS.values()) | {100 + n for n in [36, 38, 42, 43, 45, 46, 47, 49]})
|
||||
start = 16 + len(instruments) * 2
|
||||
assert len(stream) <= 65535
|
||||
header = b"MUS\x1a" + struct.pack("<6H", len(stream), start, len(PROGRAMS), 0,
|
||||
len(instruments), 0)
|
||||
score = header + struct.pack("<" + "H" * len(instruments), *instruments) + stream
|
||||
path = ROOT / "music/iron-coast.mus"
|
||||
path.parent.mkdir(exist_ok=True)
|
||||
path.write_bytes(score)
|
||||
|
||||
notes = sum(1 for group in EVENTS.values() for _, raw in group if raw[0] >> 4 == 1)
|
||||
report = {
|
||||
"title": "Coastal Steel",
|
||||
"composer": "Original authored composition for RustRaptor Iron Coast",
|
||||
"format": "MUS with explicit note velocities and standard GM programs",
|
||||
"service_clock_hz": 70,
|
||||
"tempo_bpm": 140,
|
||||
"meter": "4/4",
|
||||
"bars": 56,
|
||||
"duration_ticks": END,
|
||||
"duration_seconds": END / 70,
|
||||
"tonality": "E minor with G-major contrast and B-major turnaround",
|
||||
"sections": SECTIONS,
|
||||
"channels": {str(k): v for k, v in PROGRAMS.items()} | {"15": "standard percussion"},
|
||||
"instrument_preload_table": instruments,
|
||||
"percussion_preload_encoding": "100 + MIDI percussion key (MUS convention)",
|
||||
"events": sum(len(g) for g in EVENTS.values()),
|
||||
"notes": notes,
|
||||
"groups": len(EVENTS),
|
||||
"max_events_per_tick": max(len(g) for g in EVENTS.values()),
|
||||
"file": "music/iron-coast.mus",
|
||||
"bytes": len(score),
|
||||
"sha256": hashlib.sha256(score).hexdigest(),
|
||||
"recipe": "build_music.py",
|
||||
"sources": "Authored note sequences and deterministic arrangement; no original-game score or recording used",
|
||||
"rendering": "Existing Rust MUS decoder and GENMIDI/OPL3 synthesizer; original instrument bank remains external",
|
||||
}
|
||||
(ROOT / "provenance.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(json.dumps(report, indent=2))
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"music": [
|
||||
{
|
||||
"id": "iron_coast_music:iron_coast",
|
||||
"name": "Coastal Steel",
|
||||
"file": "music/iron-coast.mus",
|
||||
"looping": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
id = "iron_coast_music"
|
||||
name = "Iron Coast — Campaign Music"
|
||||
version = "1.0.0"
|
||||
api_version = 1
|
||||
description = "Original 96-second looping FM action score, Coastal Steel: bass, overdriven guitars, counter-lead, strings and percussion. Plays through the Rust engine's classic GENMIDI synthesizer."
|
||||
content = "content.json"
|
||||
Binary file not shown.
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"title": "Coastal Steel",
|
||||
"composer": "Original authored composition for RustRaptor Iron Coast",
|
||||
"format": "MUS with explicit note velocities and standard GM programs",
|
||||
"service_clock_hz": 70,
|
||||
"tempo_bpm": 140,
|
||||
"meter": "4/4",
|
||||
"bars": 56,
|
||||
"duration_ticks": 6720,
|
||||
"duration_seconds": 96.0,
|
||||
"tonality": "E minor with G-major contrast and B-major turnaround",
|
||||
"sections": [
|
||||
{
|
||||
"name": "Approach",
|
||||
"first_bar": 0,
|
||||
"bars": 4
|
||||
},
|
||||
{
|
||||
"name": "Breakwater riff",
|
||||
"first_bar": 4,
|
||||
"bars": 12
|
||||
},
|
||||
{
|
||||
"name": "Offshore pursuit",
|
||||
"first_bar": 16,
|
||||
"bars": 12
|
||||
},
|
||||
{
|
||||
"name": "Stormwall bridge",
|
||||
"first_bar": 28,
|
||||
"bars": 8
|
||||
},
|
||||
{
|
||||
"name": "Citadel assault",
|
||||
"first_bar": 36,
|
||||
"bars": 16
|
||||
},
|
||||
{
|
||||
"name": "Return to coast",
|
||||
"first_bar": 52,
|
||||
"bars": 4
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"0": 33,
|
||||
"1": 30,
|
||||
"2": 29,
|
||||
"3": 81,
|
||||
"4": 48,
|
||||
"15": "standard percussion"
|
||||
},
|
||||
"instrument_preload_table": [
|
||||
29,
|
||||
30,
|
||||
33,
|
||||
48,
|
||||
81,
|
||||
136,
|
||||
138,
|
||||
142,
|
||||
143,
|
||||
145,
|
||||
146,
|
||||
147,
|
||||
149
|
||||
],
|
||||
"percussion_preload_encoding": "100 + MIDI percussion key (MUS convention)",
|
||||
"events": 5520,
|
||||
"notes": 2748,
|
||||
"groups": 2855,
|
||||
"max_events_per_tick": 24,
|
||||
"file": "music/iron-coast.mus",
|
||||
"bytes": 16700,
|
||||
"sha256": "c0b5557877585d072e431b220531663de34f8364abc4f93dc17cc025c9801057",
|
||||
"recipe": "build_music.py",
|
||||
"sources": "Authored note sequences and deterministic arrangement; no original-game score or recording used",
|
||||
"rendering": "Existing Rust MUS decoder and GENMIDI/OPL3 synthesizer; original instrument bank remains external"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"decoder": "Rust MusScore::decode",
|
||||
"synthesizer": "Rust Mixer using locally installed original GENMIDI_OP2",
|
||||
"sample_rate": 22050,
|
||||
"channels": 2,
|
||||
"render_seconds": 100,
|
||||
"score_seconds": 96,
|
||||
"events": 5520,
|
||||
"notes": 2748,
|
||||
"max_events_per_tick": 24,
|
||||
"max_simultaneous_notes": 10,
|
||||
"unmatched_note_events": 0,
|
||||
"dangling_notes": 0,
|
||||
"peak_pcm16": 3558,
|
||||
"clipped_samples": 0,
|
||||
"all_one_second_blocks_non_silent": true,
|
||||
"loop_after_96_seconds_non_silent": true,
|
||||
"music_volume_zero_is_exactly_silent": true,
|
||||
"sha256": "c0b5557877585d072e431b220531663de34f8364abc4f93dc17cc025c9801057",
|
||||
"listening_review": "The existing FM engine rendered the preview. Full human listening and ordinary-play volume balance remain review items.",
|
||||
"percussion_preload_encoding": "100 + MIDI percussion key",
|
||||
"percussion_header_verification": "All eleven local original MUS scores with percussion contain 100+key for every emitted percussion key. Instrument declarations only; no original melody copied."
|
||||
}
|
||||
Reference in new issue
Block a user