fix(housing): register doors, and stop crashing on client component sheets

Players could not place any door while customizing a house, and placing other
pieces could disconnect them outright. Staff saw neither problem, because
Designer_Build only enforces ValidPiece below GameMaster.

Doors: the retail client's doors.txt separates its header rows with lines of
bare tabs. Spreadsheet.ReadLine skipped lines only when `line.Length > 0`, so a
10-tab line was accepted as the names row - every column ended up named "",
GetColumnID resolved nothing, and not one of the 230 door graphics was
registered. Unregistered pieces keep the NotAComponent sentinel, so ValidPiece
rejected every door. The client skips these lines with IsNullOrWhiteSpace; now
so do we. Verified against a retail 7.0.x doors.txt: 0 door graphics before,
230 after.

Crash: every sheet ends in a cosmetic Comment column that is never read, and
client sheets write an empty comment as a plain newline with no trailing tab.
Split('\t') then returns one field fewer than the header declares and the
parser indexed past the end, throwing IndexOutOfRangeException out of the
design packet handler. The client's parser only requires the columns up to
FeatureMask (ClassicUO's CustomHouseMisc.Parse guards on `scanf.Length >= 12`
for a 13-column misc.txt), so such a row is valid data listing real pieces.
Treat a missing trailing field as empty rather than dropping the row, which
would unregister every piece the row lists.

EnsureLoaded also set _loaded before loading, so after the throw an all -1
table stayed cached and rejected everything for players from then on - the same
symptom as #2500.

Also made the failure modes explicit rather than accidental:

- Named the table sentinels. NotAComponent (-1) is the anti-cheat guard and the
  initial state; NoFeatureRequired (0) is a piece with no expansion gate.
- A sheet with no FeatureMask column is now refused and logged. GetInt32 on a
  missing column returns NoFeatureRequired, which would have silently marked
  every piece in that sheet unconditionally placeable regardless of expansion.
- A sheet matching none of its expected tile columns is refused and logged.
  Individual missing columns stay tolerated: older sheets predate columns such
  as walls.txt's SecondAltWindowS/E.
- Catch per sheet, so one unreadable file no longer costs the other six.
- Guard the header rows: an empty file or a types-only file raised a
  NullReferenceException, and a short names row indexed past the end.
- Fall back to the component sheets when housing.bin cannot be read, rather
  than passing null into a SpanReader.

_loaded is still set before loading, deliberately: retrying would re-read every
sheet on each subsequent placement attempt from the packet path.

Sheet precedence is unchanged - the client's copies stay authoritative and
Data/Components remains the fallback.

Co-authored-by: SynPDX <mickelonis@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-08-04 19:55:27 -07:00
parent aae173a797
commit c00b907c49
No known key found for this signature in database
GPG key ID: 7D81DF26D9A5D94A
2 changed files with 220 additions and 11 deletions

View file

@ -0,0 +1,114 @@
using System;
using System.IO;
using Server.Multis;
using Xunit;
namespace UOContent.Tests;
public class SpreadsheetTests : IDisposable
{
// misc.txt's shape: 13 columns, the last being the Comment the client never reads.
private const string Types = "int\tint\tint\tint\tint\tint\tint\tint\tint\tint\tint\tint\tstring";
private const string Names =
"Category\tStyle\tTID\tPiece1\tPiece2\tPiece3\tPiece4\tPiece5\tPiece6\tPiece7\tPiece8\tFeatureMask\tComment";
private readonly string _path = Path.Combine(Path.GetTempPath(), $"muo-sheet-{Guid.NewGuid():N}.txt");
public void Dispose()
{
if (File.Exists(_path))
{
File.Delete(_path);
}
}
private Spreadsheet Write(params string[] rows)
{
var lines = new string[rows.Length + 2];
lines[0] = Types;
lines[1] = Names;
rows.CopyTo(lines, 2);
File.WriteAllLines(_path, lines);
return new Spreadsheet(_path);
}
[Fact]
public void RowMissingTrailingCommentIsStillRead()
{
// An empty Comment written without a trailing tab leaves the row one field short.
var ss = Write("0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t8");
var record = Assert.Single(ss.Records);
Assert.Equal(44, record.GetInt32(ss.GetColumnID("Piece1")));
Assert.Equal(29, record.GetInt32(ss.GetColumnID("Piece8")));
Assert.Equal(8, record.GetInt32(ss.GetColumnID("FeatureMask")));
}
[Fact]
public void ShortRowDoesNotDropLaterRows()
{
var ss = Write(
"0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t0",
"0\t1\t1060057\t45\t0\t0\t0\t0\t0\t0\t0\t0\tFieldstone Arches"
);
Assert.Equal(2, ss.Records.Length);
Assert.Equal(44, ss.Records[0].GetInt32(ss.GetColumnID("Piece1")));
Assert.Equal(45, ss.Records[1].GetInt32(ss.GetColumnID("Piece1")));
}
[Fact]
public void TabOnlySeparatorLinesAreNotMistakenForHeaderRows()
{
// The retail client's doors.txt separates its header rows this way.
var separator = new string('\t', 12);
File.WriteAllLines(
_path,
[
Types,
separator,
Names,
separator,
"0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t0\tFieldstone Archways"
]
);
var ss = new Spreadsheet(_path);
Assert.Equal(3, ss.GetColumnID("Piece1"));
Assert.Equal(11, ss.GetColumnID("FeatureMask"));
var record = Assert.Single(ss.Records);
Assert.Equal(44, record.GetInt32(ss.GetColumnID("Piece1")));
}
[Fact]
public void MissingHeaderRowsThrowsInsteadOfNullReference()
{
File.WriteAllLines(_path, [Types]);
Assert.Throws<InvalidDataException>(() => new Spreadsheet(_path));
}
[Fact]
public void HeaderWithFewerNamesThanTypesIsTolerated()
{
File.WriteAllLines(
_path,
[
Types,
"Category\tStyle\tTID\tPiece1",
"0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t0\tFieldstone Archways"
]
);
var ss = new Spreadsheet(_path);
Assert.Equal(44, ss.Records[0].GetInt32(ss.GetColumnID("Piece1")));
Assert.Equal(-1, ss.GetColumnID("FeatureMask"));
}
}

View file

@ -4,11 +4,14 @@ using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using Server.Compression;
using Server.Logging;
namespace Server.Multis;
public static class ComponentVerification
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(ComponentVerification));
private static int[] _itemTable;
private static int[] _multiTable;
private static bool _loaded;
@ -20,6 +23,15 @@ public static class ComponentVerification
// encodes them) while AOS/SE/ML/... line up unchanged.
private const int HousingTierMask = (int)HousingFlags.HousingEJ;
// Table sentinels. Slots start as NotAComponent, which CheckValidity rejects, so a piece that
// never gets registered can never be placed. NoFeatureRequired is a piece with no expansion
// requirement: walls.txt encodes pre-AOS base pieces as 0, housing.bin collapses to 0 under
// HousingTierMask.
private const int NotAComponent = -1;
private const int NoFeatureRequired = 0;
private const string FeatureMaskColumn = "FeatureMask";
public static bool IsItemValid(int itemID)
{
EnsureLoaded();
@ -33,7 +45,8 @@ public static class ComponentVerification
}
private static bool CheckValidity(int val) =>
val != -1 && (val == 0 || ((int)ExpansionInfo.CoreExpansion.HousingFlags & val) != 0);
val != NotAComponent &&
(val == NoFeatureRequired || ((int)ExpansionInfo.CoreExpansion.HousingFlags & val) != 0);
private static void EnsureLoaded()
{
@ -42,22 +55,50 @@ public static class ComponentVerification
return;
}
// Set before loading: this runs from the design packet handler, so a bad file must not
// re-read every sheet on each later placement attempt. Sheets below fail independently.
_loaded = true;
_itemTable = CreateTable(TileData.MaxItemValue);
_multiTable = CreateTable(0x4000);
var housingPath = MultiData.HousingUOPPath;
if (housingPath != null)
if (housingPath != null && TryLoadFromHousingBin(housingPath))
{
var entry = MultiData.HousingEntry;
LoadFromHousingBin(ReadUOPEntry(housingPath, entry));
return;
}
LoadFromTxtFiles();
}
private static bool TryLoadFromHousingBin(string path)
{
try
{
var data = ReadUOPEntry(path, MultiData.HousingEntry);
if (data != null)
{
LoadFromHousingBin(data);
return true;
}
logger.Warning(
"Could not decompress housing.bin from {Path}. Falling back to the component sheets",
path
);
}
catch (Exception ex)
{
logger.Warning(
ex,
"Failed to read housing.bin from {Path}. Falling back to the component sheets",
path
);
}
return false;
}
private static byte[] ReadUOPEntry(string path, UOPEntry entry)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
@ -223,16 +264,55 @@ public static class ComponentVerification
return;
}
var ss = new Spreadsheet(path);
Spreadsheet ss;
try
{
ss = new Spreadsheet(path);
}
catch (Exception ex)
{
// One unreadable sheet must not take the others down with it.
logger.Error(ex, "Could not read house components from {Path}", path);
return;
}
// GetInt32 on a missing column yields NoFeatureRequired, which would register every piece in
// the sheet as unconditionally placeable. Refuse the sheet instead.
var featureCID = ss.GetColumnID(FeatureMaskColumn);
if (featureCID < 0)
{
logger.Error(
"House component sheet {Path} has no {Column} column. Its pieces will not be registered",
path,
FeatureMaskColumn
);
return;
}
// An individual missing column is expected - older sheets predate walls.txt's
// SecondAltWindowS/E - but a sheet matching none of them is not the sheet we expect.
var tileCIDs = new int[tileColumns.Length];
var matchedColumns = 0;
for (var i = 0; i < tileColumns.Length; ++i)
{
tileCIDs[i] = ss.GetColumnID(tileColumns[i]);
if (tileCIDs[i] >= 0)
{
matchedColumns++;
}
}
var featureCID = ss.GetColumnID("FeatureMask");
if (matchedColumns == 0)
{
logger.Error(
"House component sheet {Path} has none of its expected tile columns. Its pieces will not be registered",
path
);
return;
}
for (var i = 0; i < ss.Records.Length; ++i)
{
@ -260,7 +340,7 @@ public static class ComponentVerification
for (var i = 0; i < table.Length; ++i)
{
table[i] = -1;
table[i] = NotAComponent;
}
return table;
@ -277,11 +357,18 @@ public class Spreadsheet
var types = ReadLine(ip);
var names = ReadLine(ip);
if (types == null || names == null)
{
throw new InvalidDataException($"House component sheet '{path}' is missing its header rows.");
}
m_Columns = new ColumnInfo[types.Length];
for (var i = 0; i < m_Columns.Length; ++i)
{
m_Columns[i] = new ColumnInfo(i, types[i], names[i]);
// A names row shorter than the types row leaves the extras unnamed, so nothing resolves
// to them.
m_Columns[i] = new ColumnInfo(i, types[i], i < names.Length ? names[i] : "");
}
var records = new List<DataRecord>();
@ -294,10 +381,15 @@ public class Spreadsheet
{
var ci = m_Columns[i];
// Client sheets write an empty trailing Comment as a plain newline, leaving the row
// one field short. The client only requires the columns up to FeatureMask, so treat
// the missing field as empty rather than dropping a row that lists real pieces.
var value = ci.m_DataIndex < values.Length ? values[ci.m_DataIndex] : null;
data[i] = ci.m_Type switch
{
"int" => Utility.ToInt32(values[ci.m_DataIndex]),
"string" => values[ci.m_DataIndex],
"int" => Utility.ToInt32(value),
"string" => value,
_ => data[i]
};
}
@ -327,7 +419,10 @@ public class Spreadsheet
{
while (ip.ReadLine() is { } line)
{
if (line.Length > 0)
// Whitespace-only, not merely empty: the retail client's doors.txt separates its header
// rows with lines of bare tabs, and accepting one as the names row leaves every column
// unnamed, so no door resolves. The client skips them the same way.
if (!string.IsNullOrWhiteSpace(line))
{
return line.Split('\t');
}