Skip to content

Commit ffdae88

Browse files
feat: Add independant ADG player
- Introduced OplWriteItem and PlaylistItem view models for managing OPL writes and playlist entries. - Created ViewModelBase as a base class for view models, implementing INotifyPropertyChanged. - Developed MainWindow.axaml and MainWindow.axaml.cs for the main application window, including layout and data bindings. - Implemented VolumeFeedbackControl and WaveformControl for audio visualization. - Updated solution file to include the new Cryogenic.AdgPlayer project.
1 parent f7e872b commit ffdae88

22 files changed

Lines changed: 3691 additions & 0 deletions

src/Cryogenic.AdgPlayer/App.axaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<Application xmlns="https://github.com/avaloniaui"
2+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
3+
x:Class="Cryogenic.AdgPlayer.App"
4+
RequestedThemeVariant="Default">
5+
<Application.Styles>
6+
<FluentTheme />
7+
</Application.Styles>
8+
</Application>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
namespace Cryogenic.AdgPlayer;
2+
3+
using Avalonia;
4+
using Avalonia.Controls.ApplicationLifetimes;
5+
using Avalonia.Markup.Xaml;
6+
7+
using Cryogenic.AdgPlayer.ViewModels;
8+
using Cryogenic.AdgPlayer.Views;
9+
10+
public sealed class App : Application {
11+
public override void Initialize() {
12+
AvaloniaXamlLoader.Load(this);
13+
}
14+
15+
public override void OnFrameworkInitializationCompleted() {
16+
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
17+
MainWindow mainWindow = new MainWindow();
18+
MainWindowViewModel viewModel = new MainWindowViewModel(mainWindow);
19+
mainWindow.DataContext = viewModel;
20+
desktop.Exit += (_, _) => viewModel.Dispose();
21+
desktop.MainWindow = mainWindow;
22+
}
23+
24+
base.OnFrameworkInitializationCompleted();
25+
}
26+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
namespace Cryogenic.AdgPlayer.Behaviors;
2+
3+
using Avalonia;
4+
using Avalonia.Controls;
5+
using Avalonia.Xaml.Interactivity;
6+
7+
using System.Collections.Specialized;
8+
9+
/// <summary>
10+
/// XAML behavior that auto-scrolls a ListBox to the last item when new items are added.
11+
/// Reused by all ADP scrolling panels (events, OPL writes, logs).
12+
/// </summary>
13+
public sealed class ScrollToEndBehavior : Behavior<ListBox> {
14+
private INotifyCollectionChanged? _trackedCollection;
15+
16+
/// <inheritdoc />
17+
protected override void OnAttached() {
18+
base.OnAttached();
19+
if (AssociatedObject is not null) {
20+
AssociatedObject.PropertyChanged += OnPropertyChanged;
21+
}
22+
HookCollection();
23+
}
24+
25+
/// <inheritdoc />
26+
protected override void OnDetaching() {
27+
if (AssociatedObject is not null) {
28+
AssociatedObject.PropertyChanged -= OnPropertyChanged;
29+
}
30+
UnhookCollection();
31+
base.OnDetaching();
32+
}
33+
34+
private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) {
35+
if (e.Property == ItemsControl.ItemsSourceProperty) {
36+
HookCollection();
37+
}
38+
}
39+
40+
private void HookCollection() {
41+
UnhookCollection();
42+
if (AssociatedObject?.ItemsSource is INotifyCollectionChanged notifyCollectionChanged) {
43+
_trackedCollection = notifyCollectionChanged;
44+
notifyCollectionChanged.CollectionChanged += OnCollectionChanged;
45+
}
46+
}
47+
48+
private void UnhookCollection() {
49+
if (_trackedCollection is not null) {
50+
_trackedCollection.CollectionChanged -= OnCollectionChanged;
51+
_trackedCollection = null;
52+
}
53+
}
54+
55+
private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) {
56+
if (e.Action == NotifyCollectionChangedAction.Add && AssociatedObject is not null) {
57+
int count = AssociatedObject.ItemCount;
58+
if (count > 0) {
59+
AssociatedObject.ScrollIntoView(count - 1);
60+
}
61+
}
62+
}
63+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>WinExe</OutputType>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
8+
<AvaloniaUseCompiledBindingsByDefault>True</AvaloniaUseCompiledBindingsByDefault>
9+
<NoWarn>NU1903</NoWarn>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<PackageReference Include="Avalonia" Version="11.3.12" />
14+
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
15+
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
16+
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
17+
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
18+
<PackageReference Include="Serilog" Version="4.3.1" />
19+
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
20+
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
21+
<PackageReference Include="Spice86.Core" Version="12.0.0" />
22+
<PackageReference Include="Xaml.Behaviors.Avalonia" Version="11.3.0.7" />
23+
</ItemGroup>
24+
25+
<ItemGroup>
26+
<AvaloniaResource Include="Assets\**" />
27+
<Watch Include="**\*.axaml" />
28+
</ItemGroup>
29+
</Project>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
namespace Cryogenic.AdgPlayer.Logging;
2+
3+
using Serilog.Core;
4+
using Serilog.Events;
5+
6+
using System;
7+
using System.Collections.Concurrent;
8+
9+
/// <summary>
10+
/// Serilog sink that buffers log entries for display in the Avalonia UI.
11+
/// Thread-safe — log events can arrive from any thread.
12+
/// </summary>
13+
public sealed class ObservableSerilogSink : ILogEventSink {
14+
/// <summary>
15+
/// Singleton instance shared between Serilog configuration and the ViewModel.
16+
/// </summary>
17+
public static readonly ObservableSerilogSink Instance = new();
18+
19+
private readonly ConcurrentQueue<string> _queue = new();
20+
private const int MaxEntries = 2000;
21+
22+
/// <summary>
23+
/// Fired on each new log event with the rendered message.
24+
/// May fire from any thread.
25+
/// </summary>
26+
public event Action<string>? LogReceived;
27+
28+
public void Emit(LogEvent logEvent) {
29+
string rendered = $"[{logEvent.Timestamp:HH:mm:ss} {logEvent.Level.ToString().ToUpperInvariant()[..3]}] {logEvent.RenderMessage()}";
30+
if (logEvent.Exception is not null) {
31+
rendered += Environment.NewLine + logEvent.Exception;
32+
}
33+
34+
_queue.Enqueue(rendered);
35+
while (_queue.Count > MaxEntries) {
36+
_queue.TryDequeue(out _);
37+
}
38+
39+
LogReceived?.Invoke(rendered);
40+
}
41+
42+
/// <summary>
43+
/// Drains all buffered entries.
44+
/// </summary>
45+
public string[] DrainAll() {
46+
return _queue.ToArray();
47+
}
48+
}

src/Cryogenic.AdgPlayer/Program.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
namespace Cryogenic.AdgPlayer;
2+
3+
using Avalonia;
4+
5+
using Cryogenic.AdgPlayer.Logging;
6+
7+
using Serilog;
8+
9+
using System;
10+
11+
internal static class Program {
12+
[STAThread]
13+
public static void Main(string[] args) {
14+
Log.Logger = new LoggerConfiguration()
15+
.MinimumLevel.Debug()
16+
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
17+
.WriteTo.File("logs/adgplayer-.log",
18+
rollingInterval: RollingInterval.Day,
19+
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
20+
.WriteTo.Sink(ObservableSerilogSink.Instance)
21+
.CreateLogger();
22+
23+
try {
24+
Log.Information("Cryogenic ADG Player starting");
25+
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
26+
} catch (Exception ex) {
27+
Log.Fatal(ex, "Unhandled exception");
28+
} finally {
29+
Log.CloseAndFlush();
30+
}
31+
}
32+
33+
public static AppBuilder BuildAvaloniaApp() {
34+
return AppBuilder.Configure<App>()
35+
.UsePlatformDetect()
36+
.LogToTrace()
37+
.WithInterFont();
38+
}
39+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
namespace Cryogenic.AdgPlayer.Services;
2+
3+
using Serilog;
4+
5+
/// <summary>
6+
/// HSQ decompression for ADG/ADP music files from DUNE.DAT.
7+
/// Many files are HSQ-compressed (LZ77 variant with a 6-byte header).
8+
/// Ported from OpenRakis/tools/dune-ds/source/unhsq.c via the MT32 player.
9+
/// Checksum: (byte0 + byte1 + byte2 + byte3 + byte4 + byte5) &amp; 0xFF == 0xAB
10+
/// </summary>
11+
public sealed partial class DuneAdgPlayerEngine {
12+
private static readonly ILogger HsqLogger = Log.ForContext("Subsystem", "HSQ");
13+
14+
/// <summary>
15+
/// Attempts HSQ decompression; returns null if the header is not a valid HSQ signature.
16+
/// </summary>
17+
private static byte[]? TryDecompressHsq(byte[] source) {
18+
if (source.Length < 6) {
19+
return null;
20+
}
21+
22+
int uncompressedSize = source[0] | (source[1] << 8);
23+
if (uncompressedSize == 0 || uncompressedSize > 0x100000) {
24+
return null;
25+
}
26+
27+
int checksum = (source[0] + source[1] + source[2] + source[3] + source[4] + source[5]) & 0xFF;
28+
if (checksum != 0xAB) {
29+
return null;
30+
}
31+
32+
try {
33+
byte[] result = DecompressHsq(source, uncompressedSize);
34+
HsqLogger.Information("HSQ decompressed: {SrcLen} → {DstLen} bytes", source.Length, result.Length);
35+
return result;
36+
} catch {
37+
HsqLogger.Warning("HSQ decompression failed, using raw data");
38+
return null;
39+
}
40+
}
41+
42+
/// <summary>
43+
/// Core HSQ decompression. Faithful port of unhsq_unpack2 from OpenRakis.
44+
/// </summary>
45+
private static byte[] DecompressHsq(byte[] source, int uncompressedSize) {
46+
byte[] output = new byte[uncompressedSize];
47+
int srcPos = 6;
48+
int dstPos = 0;
49+
int bitBuffer = 1;
50+
51+
while (dstPos < uncompressedSize && srcPos < source.Length) {
52+
if (HsqGetBit(source, ref srcPos, ref bitBuffer)) {
53+
if (srcPos >= source.Length) {
54+
break;
55+
}
56+
output[dstPos++] = source[srcPos++];
57+
} else {
58+
int count;
59+
int offset;
60+
61+
if (HsqGetBit(source, ref srcPos, ref bitBuffer)) {
62+
if (srcPos + 1 >= source.Length) {
63+
break;
64+
}
65+
byte first = source[srcPos++];
66+
byte second = source[srcPos++];
67+
68+
count = first & 7;
69+
int word = first | (second << 8);
70+
int offsetBits = word >> 3;
71+
offset = offsetBits - 8192;
72+
73+
if (count == 0) {
74+
if (srcPos >= source.Length) {
75+
break;
76+
}
77+
count = source[srcPos++];
78+
if (count == 0) {
79+
break;
80+
}
81+
}
82+
} else {
83+
count = HsqGetBit(source, ref srcPos, ref bitBuffer) ? 2 : 0;
84+
count |= HsqGetBit(source, ref srcPos, ref bitBuffer) ? 1 : 0;
85+
86+
if (srcPos >= source.Length) {
87+
break;
88+
}
89+
offset = source[srcPos++] - 256;
90+
}
91+
92+
count += 2;
93+
int copyFrom = dstPos + offset;
94+
if (copyFrom < 0) {
95+
break;
96+
}
97+
98+
for (int i = 0; i < count && dstPos < uncompressedSize; i++) {
99+
output[dstPos++] = output[copyFrom + i];
100+
}
101+
}
102+
}
103+
104+
return output;
105+
}
106+
107+
/// <summary>
108+
/// Reads one bit from the HSQ bit stream using the sentinel approach.
109+
/// </summary>
110+
private static bool HsqGetBit(byte[] source, ref int srcPos, ref int bitBuffer) {
111+
if (bitBuffer == 1) {
112+
if (srcPos + 1 >= source.Length) {
113+
return false;
114+
}
115+
bitBuffer = 0x10000 | source[srcPos] | (source[srcPos + 1] << 8);
116+
srcPos += 2;
117+
}
118+
119+
bool bit = (bitBuffer & 1) != 0;
120+
bitBuffer >>= 1;
121+
return bit;
122+
}
123+
}

0 commit comments

Comments
 (0)