|
| 1 | +using System.Buffers.Binary; |
| 2 | +using System.Security.Cryptography; |
| 3 | +using SonicAudioLib.CriMw; |
| 4 | +using VGAudio.Containers.Wave; |
| 5 | +using VGAudio.Formats.Pcm16; |
| 6 | + |
| 7 | +namespace ChuChartManager; |
| 8 | + |
| 9 | +public static class AcbPreviewHelper |
| 10 | +{ |
| 11 | + public record PreviewTime(double StartMs, double EndMs); |
| 12 | + |
| 13 | + // CRIWARE ADX2 序列命令的固定中段(逆向):其前 4 字节为 loopStart(ms 大端), |
| 14 | + // 本标记后 4 字节为 loopEnd,再之后为结束符 0x0FA0;标记前导为 0x03 0xE7 0x04 |
| 15 | + private static readonly byte[] Marker = [0x07, 0xD0, 0x04, 0x00, 0x02, 0x00, 0x01, 0x07, 0xD1, 0x04]; |
| 16 | + |
| 17 | + public static PreviewTime? Read(string acbPath) |
| 18 | + { |
| 19 | + if (!File.Exists(acbPath)) return null; |
| 20 | + |
| 21 | + var bytes = File.ReadAllBytes(acbPath); |
| 22 | + var m = Locate(bytes); |
| 23 | + if (m < 0) return null; |
| 24 | + |
| 25 | + var start = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(m - 4, 4)); |
| 26 | + var end = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(m + 10, 4)); |
| 27 | + return new PreviewTime(start, end); |
| 28 | + } |
| 29 | + |
| 30 | + /// <summary>从 template_preview.acb 整体重建 ACB(对齐 MCM CreateAcbWithPreview),AWB 不动</summary> |
| 31 | + public static void Write(string acbPath, string awbPath, int musicId, uint startMs, uint endMs) |
| 32 | + { |
| 33 | + var awbBytes = File.ReadAllBytes(awbPath); |
| 34 | + var hca = AudioHelper.ExtractHcaFromAwb(awbPath) ?? throw new InvalidDataException("AWB 中未找到 HCA"); |
| 35 | + var wav = AudioHelper.DecodeHcaToWav(hca) ?? throw new InvalidDataException("HCA 解码失败"); |
| 36 | + |
| 37 | + var format = new WaveReader().Read(wav).GetFormat<Pcm16Format>(); |
| 38 | + var durationMs = (uint)(format.SampleCount * 1000.0 / format.SampleRate); |
| 39 | + if (endMs > durationMs) endMs = durationMs; |
| 40 | + if (startMs >= endMs) throw new ArgumentException("预览起点必须小于终点"); |
| 41 | + |
| 42 | + var templatePath = Path.Combine(StaticSettings.ExeDir, "Resources", "template_preview.acb"); |
| 43 | + var acb = LoadTable(File.ReadAllBytes(templatePath)); |
| 44 | + |
| 45 | + acb.Rows[0]["Name"] = $"music{musicId:D4}"; |
| 46 | + |
| 47 | + UpdateSubTable(acb, "CueTable", t => t.Rows[0]["Length"] = durationMs); |
| 48 | + UpdateSubTable(acb, "TrackEventTable", t => t.Rows[1]["Command"] = BuildCommand(startMs, endMs)); |
| 49 | + UpdateSubTable(acb, "StreamAwbHash", t => |
| 50 | + { |
| 51 | + t.Rows[0]["Name"] = $"music{musicId:D4}"; |
| 52 | + t.Rows[0]["Hash"] = MD5.HashData(awbBytes); |
| 53 | + }); |
| 54 | + UpdateSubTable(acb, "WaveformTable", t => |
| 55 | + { |
| 56 | + t.Rows[0]["SamplingRate"] = (ushort)format.SampleRate; |
| 57 | + t.Rows[0]["NumSamples"] = (uint)format.SampleCount; |
| 58 | + }); |
| 59 | + UpdateSubTable(acb, "StreamAwbAfs2Header", t => t.Rows[0]["Header"] = ExtractAfs2Header(awbBytes)); |
| 60 | + |
| 61 | + acb.WriterSettings = CriTableWriterSettings.Adx2Settings; |
| 62 | + using var fs = File.Create(acbPath); |
| 63 | + acb.Write(fs); |
| 64 | + } |
| 65 | + |
| 66 | + private static CriTable LoadTable(byte[] bytes) |
| 67 | + { |
| 68 | + var table = new CriTable(); |
| 69 | + table.Read(new MemoryStream(bytes)); |
| 70 | + return table; |
| 71 | + } |
| 72 | + |
| 73 | + private static void UpdateSubTable(CriTable acb, string name, Action<CriTable> mutate) |
| 74 | + { |
| 75 | + if (acb.Rows[0][name] is not byte[] subBytes || subBytes.Length == 0) |
| 76 | + throw new InvalidDataException($"模板缺少 {name}"); |
| 77 | + |
| 78 | + var sub = LoadTable(subBytes); |
| 79 | + mutate(sub); |
| 80 | + using var ms = new MemoryStream(); |
| 81 | + sub.Write(ms); |
| 82 | + acb.Rows[0][name] = ms.ToArray(); |
| 83 | + } |
| 84 | + |
| 85 | + // AFS2 头长度公式(与 MCM 相同,已对 CHUNITHM AWB 实测验证): |
| 86 | + // 16 字节固定头 + (id宽+offset宽+length宽) × (文件数+1) |
| 87 | + private static byte[] ExtractAfs2Header(byte[] awbBytes) |
| 88 | + { |
| 89 | + var count = BitConverter.ToInt32(awbBytes, 8) + 1; |
| 90 | + var headSize = 16 + awbBytes[5] * count + awbBytes[6] * count + awbBytes[7] * count; |
| 91 | + return awbBytes[..headSize]; |
| 92 | + } |
| 93 | + |
| 94 | + private static byte[] BuildCommand(uint startMs, uint endMs) |
| 95 | + { |
| 96 | + var cmd = new byte[27]; |
| 97 | + cmd[0] = 0x03; cmd[1] = 0xE7; cmd[2] = 0x04; |
| 98 | + BinaryPrimitives.WriteUInt32BigEndian(cmd.AsSpan(3, 4), startMs); |
| 99 | + Marker.CopyTo(cmd.AsSpan(7)); |
| 100 | + BinaryPrimitives.WriteUInt32BigEndian(cmd.AsSpan(17, 4), endMs); |
| 101 | + ReadOnlySpan<byte> tail = [0x0F, 0xA0, 0x00, 0x00, 0x00, 0x00]; |
| 102 | + tail.CopyTo(cmd.AsSpan(21)); |
| 103 | + return cmd; |
| 104 | + } |
| 105 | + |
| 106 | + private static int Locate(byte[] bytes) |
| 107 | + { |
| 108 | + var idx = IndexOf(bytes, Marker); |
| 109 | + if (idx < 7 || idx + 16 > bytes.Length) return -1; |
| 110 | + if (bytes[idx - 7] != 0x03 || bytes[idx - 6] != 0xE7 || bytes[idx - 5] != 0x04) return -1; |
| 111 | + if (bytes[idx + 14] != 0x0F || bytes[idx + 15] != 0xA0) return -1; |
| 112 | + return idx; |
| 113 | + } |
| 114 | + |
| 115 | + private static int IndexOf(byte[] hay, byte[] needle) |
| 116 | + { |
| 117 | + for (var i = 0; i <= hay.Length - needle.Length; i++) |
| 118 | + { |
| 119 | + var ok = true; |
| 120 | + for (var j = 0; j < needle.Length; j++) |
| 121 | + { |
| 122 | + if (hay[i + j] != needle[j]) { ok = false; break; } |
| 123 | + } |
| 124 | + if (ok) return i; |
| 125 | + } |
| 126 | + return -1; |
| 127 | + } |
| 128 | +} |
| 129 | + |
0 commit comments