Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Ultima/ASCIIFont.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ public static unsafe void Initialize()
string path = Files.GetFilePath("fonts.mul");
if (path == null)
{
// No fonts in this client: drop the previous client's fonts.
System.Array.Clear(Fonts);
return;
}

Expand Down
98 changes: 98 additions & 0 deletions Ultima/AnimationEdit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -285,10 +285,32 @@ public static void LoadFromVD(int fileType, int body, BinaryReader bin)
}
}

/// <summary>
/// Throws before anything is written when a frame of <paramref name="anim"/> can't be stored.
/// </summary>
/// <exception cref="InvalidOperationException">A frame does not fit its run headers.</exception>
private static void EnsureFramesFit(AnimIdx anim, string where)
{
int frame = anim?.FindFrameOutsideRunHeaders() ?? -1;
if (frame >= 0)
{
throw new InvalidOperationException(
$"{where}, frame {frame}: pixels lie more than {FrameEdit.MaxCenterDistance} pixels from the frame center, " +
"which the animation format can't store. Move the center closer or crop the frame. Nothing was written.");
}
}

public static void ExportToVD(int fileType, int body, string file)
{
AnimIdx[] cache = GetCache(fileType);
GetFileIndex(body, fileType, 0, 0, out FileIndex fileIndex, out int index);

int bodyLength = Animations.GetAnimLength(body, fileType) * 5;
for (int i = 0; i < bodyLength && cache != null && index + i < cache.Length; ++i)
{
EnsureFramesFit(cache[index + i], $"Body {body}, action {i / 5}, direction {i % 5}");
}

using (var fs = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.Write))
using (var bin = new BinaryWriter(fs))
{
Expand Down Expand Up @@ -357,6 +379,11 @@ public static bool IsActionInRange(int fileType, int body, int action)
/// </summary>
public static void ExportActionToVD(int fileType, int body, int action, string file)
{
for (int dir = 0; dir < 5; ++dir)
{
EnsureFramesFit(GetAnimation(fileType, body, action, dir), $"Body {body}, action {action}, direction {dir}");
}

using (var fs = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.Write))
using (var bin = new BinaryWriter(fs))
{
Expand Down Expand Up @@ -470,6 +497,12 @@ public static void Save(int fileType, string path)
string idx = Path.Combine(path, filename + ".idx");
string mul = Path.Combine(path, filename + ".mul");

// Checked first, so a frame that can't be stored leaves the files untouched.
for (int i = 0; cache != null && i < cache.Length; ++i)
{
EnsureFramesFit(cache[i], $"{filename} entry {i}");
}

using (var fsidx = new FileStream(idx, FileMode.Create, FileAccess.Write, FileShare.Write))
using (var fsmul = new FileStream(mul, FileMode.Create, FileAccess.Write, FileShare.Write))
using (var binidx = new BinaryWriter(fsidx))
Expand Down Expand Up @@ -1052,6 +1085,22 @@ private unsafe void SavePaletteImage(string filename, ImageFormat imageFormat)
}
}

/// <summary>
/// Index of the first frame that does not fit its run headers, or -1 when all of them do.
/// </summary>
public int FindFrameOutsideRunHeaders()
{
for (int i = 0; Frames != null && i < Frames.Count; ++i)
{
if (Frames[i]?.FitsRunHeaders == false)
{
return i;
}
}

return -1;
}

public void ReplacePalette(ushort[] palette)
{
Palette = palette;
Expand Down Expand Up @@ -1295,8 +1344,57 @@ public unsafe FrameEdit(Bitmap bit, ushort[] palette, int centerX, int centerY)
bit.UnlockBits(bd);
}

// A run header stores each offset in 10 bits, biased by 512 (so -512..511 around the center),
// and the run length in 12 bits.
private const int _maxOffset = 0x3FF;
private const int _maxRun = 0xFFF;

/// <summary>
/// Most pixels a frame can have on either side of its center, per axis.
/// </summary>
public const int MaxCenterDistance = 512;

/// <summary>
/// True when every run fits a run header. A frame that does not would be saved with its
/// offsets wrapped into the neighbouring header fields.
/// </summary>
public bool FitsRunHeaders => CanCenterAt(Center.X, Center.Y);

/// <summary>
/// True when the frame can be moved to center (<paramref name="x"/>, <paramref name="y"/>)
/// and still fit its run headers.
/// </summary>
public bool CanCenterAt(int x, int y)
{
if (RawData == null)
{
return true;
}

int dx = Center.X - x;
int dy = Center.Y - y;
foreach (Raw raw in RawData)
{
int offsetX = raw.offsetX + dx;
int offsetY = raw.offsetY + dy;
if (offsetX < 0 || offsetX > _maxOffset || offsetY < 0 || offsetY > _maxOffset || raw.run < 0 || raw.run > _maxRun)
{
return false;
}
}

return true;
}

/// <exception cref="ArgumentOutOfRangeException">The frame would not fit its run headers at that center.</exception>
public void ChangeCenter(int x, int y)
{
if (!CanCenterAt(x, y))
{
throw new ArgumentOutOfRangeException(nameof(x),
$"Center {x},{y} puts pixels more than {MaxCenterDistance} pixels from the center, which a frame can't store.");
}

for (int i = 0; i < RawData.Length; i++)
{
RawData[i].offsetX += Center.X;
Expand Down
3 changes: 3 additions & 0 deletions Ultima/BodyConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public static void Initialize()
string path = Files.GetFilePath("bodyconv.def");
if (path == null)
{
// No conversions in this client: drop the previous client's tables.
Table1 = Table2 = Table3 = Table4 = Table5 = null;
_reverse1 = _reverse2 = _reverse3 = _reverse4 = _reverse5 = null;
return;
}

Expand Down
10 changes: 6 additions & 4 deletions Ultima/FileIndex.cs
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,9 @@ public Stream Seek(int index, out int length, out int extra, out bool patched)
if (IsVerdataPatched(e))
{
patched = true;
Verdata.Seek(e.Lookup);
return Verdata.Stream;

// A private copy: the verdata stream is shared by every client file and every thread.
return Verdata.ReadPatch(e.Lookup, length);
}

if (e.Length < 0)
Expand Down Expand Up @@ -377,8 +378,9 @@ public Stream Seek(int index, ref IEntry entry, out bool patched)
if (IsVerdataPatched(e))
{
patched = true;
Verdata.Seek(e.Lookup);
return Verdata.Stream;

// A private copy: the verdata stream is shared by every client file and every thread.
return Verdata.ReadPatch(e.Lookup, length);
}

if (e.Length < 0)
Expand Down
34 changes: 34 additions & 0 deletions Ultima/Helpers/TileDataHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,40 @@ public static string ReadNameString(byte[] buffer, int len)
return Encoding.ASCII.GetString(buffer, 0, count);
}

/// <summary>
/// Decodes a NUL-terminated tiledata name field. Shipped names are ASCII apart from a few in UTF-8
/// (item 0xA387 "lion’s_mane_jelly" from 7.0.98.1 on), so valid UTF-8 is read as UTF-8 and
/// anything else byte for byte as Latin-1.
/// </summary>
public static string DecodeName(ReadOnlySpan<byte> field)
{
int length = field.IndexOf((byte)0);
ReadOnlySpan<byte> text = length < 0 ? field : field[..length];

return System.Text.Unicode.Utf8.IsValid(text) ? Encoding.UTF8.GetString(text) : Encoding.Latin1.GetString(text);
}

/// <summary>
/// Encodes a name into a NUL-padded field as UTF-8, cut at a character boundary when too long.
/// </summary>
public static void EncodeName(string name, Span<byte> field)
{
field.Clear();
if (string.IsNullOrEmpty(name))
{
return;
}

byte[] bytes = Encoding.UTF8.GetBytes(name);
int length = Math.Min(bytes.Length, field.Length);
while (length > 0 && length < bytes.Length && (bytes[length] & 0xC0) == 0x80)
{
--length;
}

bytes.AsSpan(0, length).CopyTo(field);
}

/// <summary>
/// Reads a NUL-padded ASCII name from the start of <paramref name="buffer"/>,
/// up to 20 bytes. Lets callers avoid pinning + Marshal.PtrToStructure.
Expand Down
71 changes: 39 additions & 32 deletions Ultima/Skills.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,40 +19,41 @@ public static List<SkillInfo> SkillEntries
return _skillEntries;
}

_skillEntries = new List<SkillInfo>();
for (int i = 0; i < _fileIndex.IndexLength; ++i)
{
SkillInfo info = GetSkill(i);
if (info == null)
{
break;
}

_skillEntries.Add(info);
}
_skillEntries = LoadEntries();
return _skillEntries;
}
set { _skillEntries = value; }
}

/// <summary>
/// ReReads skills.mul
/// Reads every slot. The list position is the skill id, so an empty slot is kept as a
/// placeholder with no name (see <see cref="SkillInfo.IsEmpty"/>) instead of ending the list.
/// Empty slots after the last skill are left out; Save writes them back.
/// </summary>
public static void Reload()
private static List<SkillInfo> LoadEntries()
{
_fileIndex?.Dispose();
_fileIndex = new FileIndex("skills.idx", "skills.mul", 16);
_skillEntries = new List<SkillInfo>();
var entries = new List<SkillInfo>();
for (int i = 0; i < _fileIndex.IndexLength; ++i)
{
SkillInfo info = GetSkill(i);
if (info == null)
{
break;
}
entries.Add(GetSkill(i) ?? new SkillInfo(i, string.Empty, false, 0));
}

_skillEntries.Add(info);
while (entries.Count > 0 && entries[^1].IsEmpty)
{
entries.RemoveAt(entries.Count - 1);
}

return entries;
}

/// <summary>
/// ReReads skills.mul
/// </summary>
public static void Reload()
{
_fileIndex?.Dispose();
_fileIndex = new FileIndex("skills.idx", "skills.mul", 16);
_skillEntries = LoadEntries();
}

/// <summary>
Expand All @@ -73,28 +74,28 @@ public static SkillInfo GetSkill(int index)
return null;
}

using (var bin = new BinaryReader(stream))
// leaveOpen: the stream is the index's shared file stream.
using (var bin = new BinaryReader(stream, Encoding.Latin1, leaveOpen: true))
{
bool action = bin.ReadBoolean();
string name = ReadNameString(bin, length - 1);
return new SkillInfo(index, name, action, extra);
}
}

private static readonly byte[] _stringBuffer = new byte[1024];

private static string ReadNameString(BinaryReader bin, int length)
{
bin.Read(_stringBuffer, 0, length);
int count;
for (count = 0; count < length && _stringBuffer[count] != 0; ++count)
byte[] bytes = bin.ReadBytes(length);

// The name ends at the first null byte.
int count = System.Array.IndexOf(bytes, (byte)0);
if (count < 0)
{
// TODO: this loop is weird
//;
count = bytes.Length;
}

// Names are single bytes; Latin-1 keeps every byte as loaded so saving writes it back unchanged
return Encoding.Latin1.GetString(_stringBuffer, 0, count);
return Encoding.Latin1.GetString(bytes, 0, count);
}

public static void Save(string path)
Expand All @@ -112,7 +113,7 @@ public static void Save(string path)
for (int i = 0; i < count; ++i)
{
SkillInfo skill = (i < entries.Count) ? entries[i] : null;
if (skill == null)
if (skill == null || skill.IsEmpty)
{
binidx.Write(-1); // lookup
binidx.Write(0); // length
Expand Down Expand Up @@ -156,6 +157,12 @@ public string Name

public int Extra { get; }

/// <summary>
/// An empty slot: a skill without a name is saved as an unused entry.
/// </summary>
[System.ComponentModel.Browsable(false)]
public bool IsEmpty => string.IsNullOrEmpty(_name);

public SkillInfo(int nr, string name, bool action, int extra)
{
Index = nr;
Expand Down
Loading