Skip to content

Instantly share code, notes, and snippets.

@pleonex
Created September 18, 2023 13:09
Show Gist options
  • Save pleonex/635a394e2d616c6f33e162880f873b39 to your computer and use it in GitHub Desktop.
Save pleonex/635a394e2d616c6f33e162880f873b39 to your computer and use it in GitHub Desktop.
Nintendo DS BMG text format deserializer
// Copyright(c) 2023 SceneGate
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
namespace SceneGate.Ekona.Text;
using System;
using System.Text;
using Yarhl.FileFormat;
using Yarhl.IO;
public class Binary2NitroBinaryMessages : IConverter<IBinary, NitroBinaryMessages>
{
private const string MagicStamp = "MESGbmg1";
private DataReader reader = null!;
private BinaryBmg bmg = null!;
private NitroBinaryMessages model = null!;
public NitroBinaryMessages Convert(IBinary source)
{
source.Stream.Position = 0;
reader = new DataReader(source.Stream);
bmg = new BinaryBmg();
model = new NitroBinaryMessages();
ReadHeader();
reader.DefaultEncoding = model.TextEncoding;
// Required sections: INF1 and DAT1
FindSections();
if (bmg.Inf1Offset == 0) {
throw new FormatException("Missing required section INF1");
} else if (bmg.Dat1Offset == 0) {
throw new FormatException("Missing required section DAT1");
}
ReadSectionsMetadata();
ReadScriptMessages();
return model;
}
private void ReadHeader()
{
string stamp = reader.ReadString(8, Encoding.ASCII);
if (stamp != MagicStamp) {
throw new FormatException($"Invalid magic stamp: {stamp}");
}
uint fileSize = reader.ReadUInt32();
if (fileSize > reader.Stream.Length) {
throw new FormatException(
"Stream is not big enough. " +
$"Expected {fileSize} bytes, but stream is {reader.Stream.Length} bytes");
}
bmg.NumSections = reader.ReadInt32();
int encoding = reader.ReadInt32();
model.TextEncoding = encoding switch {
2 => Encoding.Unicode,
_ => throw new FormatException($"Invalid encoding ID: {encoding}"),
};
// 12 bytes reserved (as far as we know).
reader.SkipPadding(0x10);
bmg.SectionsOffset = reader.Stream.Position;
}
private void FindSections()
{
reader.Stream.Position = bmg.SectionsOffset;
for (int i = 0; i < bmg.NumSections; i++) {
string currentId = reader.ReadString(4, Encoding.ASCII);
uint sectionSize = reader.ReadUInt32();
switch (currentId) {
// Required
case "INF1":
bmg.Inf1Offset = reader.Stream.Position;
break;
case "DAT1":
bmg.Dat1Offset = reader.Stream.Position;
break;
// Optional
case "MID1":
bmg.Mid1Offset = reader.Stream.Position;
break;
case "STR1":
break; // unknown
// Deprecated
case "FLI1":
case "FLW1":
break;
default:
throw new FormatException($"Unknown section: {currentId}");
}
reader.Stream.Position += sectionSize - 8;
}
}
private void ReadSectionsMetadata()
{
ReadInf1Header();
if (bmg.Mid1Offset != 0) {
ReadMid1Header();
}
}
private void ReadInf1Header()
{
reader.Stream.Position = bmg.Inf1Offset;
bmg.NumMessages = reader.ReadInt16();
bmg.InfoEntrySize = reader.ReadInt16();
model.Unknown1 = reader.ReadInt16();
model.Unknown2 = reader.ReadInt16();
model.AttributesKind = bmg.InfoEntrySize switch {
4 => MessageAttributesKind.None,
8 => MessageAttributesKind.Simple,
12 => MessageAttributesKind.Full,
_ => throw new FormatException($"Invalid entry size: {bmg.InfoEntrySize}"),
};
}
private void ReadMid1Header()
{
reader.Stream.Position = bmg.Mid1Offset;
if (reader.ReadUInt16() != bmg.NumMessages) {
throw new FormatException($"IDs and messages do not match");
}
byte idKind = reader.ReadByte();
if (idKind != 0x10) {
throw new FormatException($"Unknown MID section: {idKind}");
}
model.IdKind = MessageIdKind.Uid;
byte unk1 = reader.ReadByte();
uint unk2 = reader.ReadUInt32();
if (unk1 != 0 || unk2 != 0) {
throw new FormatException($"Unknown reserved values: {unk1:X},{unk2:X}");
}
}
private void ReadScriptMessages()
{
reader.Stream.Position = bmg.Inf1DataOffset;
for (int i = 0; i < bmg.NumMessages; i++) {
var entry = new NitroMessage();
uint offset = reader.ReadUInt32();
if (bmg.InfoEntrySize > 4) {
entry.Attribute4_5 = reader.ReadUInt16();
entry.Attribute6_7 = reader.ReadUInt16();
}
if (bmg.InfoEntrySize > 8) {
entry.Attribute8 = reader.ReadByte();
entry.Attribute9 = reader.ReadByte();
entry.AttributeA = reader.ReadByte();
entry.AttributeB = reader.ReadByte();
}
reader.Stream.PushToPosition(bmg.Dat1Offset + offset);
entry.Text = ReadScript();
reader.Stream.PopPosition();
if (bmg.Mid1DataOffset != 0) {
reader.Stream.PushToPosition(bmg.Mid1DataOffset + (i * 4));
entry.Id = reader.ReadInt32();
reader.Stream.PopPosition();
}
model.Add(entry);
}
}
private string ReadScript()
{
var builder = new StringBuilder();
// This algoritm only works for UTF-16
ushort codeUnit = reader.ReadUInt16();
while (codeUnit != 0) {
if (codeUnit != 0x001A) {
// in C# a char is a UTF-16 code unit, no need to further decode.
_ = builder.Append((char)codeUnit);
} else {
ReadScriptFunction(builder);
}
codeUnit = reader.ReadUInt16();
}
return builder.ToString();
}
private void ReadScriptFunction(StringBuilder builder)
{
byte size = reader.ReadByte();
byte classId = reader.ReadByte();
ushort funcId = reader.ReadUInt16();
int numArgs = (size - 6) / 2;
short[] funcArgs = new short[numArgs];
for (int i = 0; i < numArgs; i++) {
funcArgs[i] = reader.ReadInt16();
}
_ = builder.Append('{')
.Append(classId.ToString("X"))
.Append("::")
.Append(funcId.ToString("X"))
.Append('(')
.AppendJoin(", ", funcArgs)
.Append(")}");
}
private sealed class BinaryBmg
{
public int NumSections { get; set; }
public int NumMessages { get; set; }
public int InfoEntrySize { get; set; }
public long SectionsOffset { get; set; }
public long Inf1Offset { get; set; }
public long Inf1DataOffset => Inf1Offset > 0 ? Inf1Offset + 8 : 0;
public long Dat1Offset { get; set; }
public long Mid1Offset { get; set; }
public long Mid1DataOffset => Mid1Offset > 0 ? Mid1Offset + 8 : 0;
}
}
/// <summary>
/// File format for .bmg (Binary MessaGes) files found in Nin games.
/// </summary>
public class NitroBinaryMessages : IFormat
{
private readonly List<NitroMessage> messages = new();
public short Unknown1 { get; set; }
public short Unknown2 { get; set; }
public MessageAttributesKind AttributesKind { get; set; }
public MessageIdKind IdKind { get; set; }
public IReadOnlyList<NitroMessage> Messages => messages;
public Encoding TextEncoding { get; set; } = Encoding.Unicode;
public void Add(NitroMessage message)
{
if (IdKind == MessageIdKind.Uid && message.Id == -1) {
throw new ArgumentException("Message must have ID", nameof(message));
}
messages.Add(message);
}
}
/// <summary>
/// Message entry of a <see cref="NitroBinaryMessages"/>.
/// </summary>
public class NitroMessage
{
public int Id { get; set; } = -1;
public string Text { get; set; } = string.Empty;
public ushort Attribute4_5 { get; set; } // unconfirmed
public ushort Attribute6_7 { get; set; }
public byte Attribute8 { get; set; } // unconfirmed
public byte Attribute9 { get; set; }
public byte AttributeA { get; set; } // unconfirmed
public byte AttributeB { get; set; }
}
public enum MessageIdKind
{
None = 0,
Uid,
}
public enum MessageAttributesKind
{
None = 0,
Simple,
Full,
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment