Merge pull request 'feat(asset-bridge): the UOP animation reader, and 235 bodies the legacy path cannot see (Phase 4)' (#31) from feat/asset-bridge-p4 into edge
Reviewed-on: #31
This commit is contained in:
@@ -76,8 +76,14 @@ namespace Server.Custom.Bridge
|
|||||||
/// Bump it whenever extraction changes what it produces from unchanged input. It is
|
/// Bump it whenever extraction changes what it produces from unchanged input. It is
|
||||||
/// the same rule <c>spawnAtlasSource.js</c>'s `PARSER_VERSION` follows, and it applies
|
/// the same rule <c>spawnAtlasSource.js</c>'s `PARSER_VERSION` follows, and it applies
|
||||||
/// here more rather than less: this pipeline derives far more from far less.
|
/// here more rather than less: this pipeline derives far more from far less.
|
||||||
|
///
|
||||||
|
/// **2** — phase 4 (§4.3, §4.9). The catalogue now falls back to
|
||||||
|
/// <c>AnimationFrame*.uop</c> for bodies the legacy path has nothing for, which on a
|
||||||
|
/// stock client is 235 new sprites and two of them player-character bodies; and the
|
||||||
|
/// player-body set no longer carries ghost ids. Every client file is byte-identical
|
||||||
|
/// and the answer is different, which is precisely what this number exists to say.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int EXTRACTOR_VERSION = 1;
|
public const int EXTRACTOR_VERSION = 2;
|
||||||
|
|
||||||
// ── the one slot (§3.2) ──────────────────────────────────────────────────────────────
|
// ── the one slot (§3.2) ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -325,6 +331,8 @@ namespace Server.Custom.Bridge
|
|||||||
"anim3.idx", "anim3.mul",
|
"anim3.idx", "anim3.mul",
|
||||||
"anim4.idx", "anim4.mul",
|
"anim4.idx", "anim4.mul",
|
||||||
"anim5.idx", "anim5.mul",
|
"anim5.idx", "anim5.mul",
|
||||||
|
"animationframe1.uop", "animationframe2.uop", "animationframe3.uop",
|
||||||
|
"animationframe4.uop", "animationframe6.uop",
|
||||||
"body.def", "bodyconv.def",
|
"body.def", "bodyconv.def",
|
||||||
"hues.mul",
|
"hues.mul",
|
||||||
"verdata.mul"
|
"verdata.mul"
|
||||||
@@ -425,12 +433,22 @@ namespace Server.Custom.Bridge
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return Files.GetFilePath(name);
|
string path = Files.GetFilePath(name);
|
||||||
|
|
||||||
|
if (path != null)
|
||||||
|
return path;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return null;
|
// Ultima's lookup reads the registry on Windows; a host where that throws still
|
||||||
|
// has the directories ServUO itself booted from, which is what the fallback uses.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `Ultima.Files` has a fixed table of file names that predates UOP animations, so it
|
||||||
|
// answers null for every `AnimationFrame*.uop` however present they are (§4.3). Phase
|
||||||
|
// 4 added those to this list, so the fallback is what makes their size, mtime and hash
|
||||||
|
// reachable at all.
|
||||||
|
return BridgeUop.FindClientFile(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static long ToUnixMs(DateTime utc)
|
private static long ToUnixMs(DateTime utc)
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ namespace Server.Custom.Bridge
|
|||||||
/// listing and a character sheet render. Everything deeper — every action, every frame —
|
/// listing and a character sheet render. Everything deeper — every action, every frame —
|
||||||
/// is the same addressing scheme at a deeper key, fetched on demand in a later phase; this
|
/// is the same addressing scheme at a deeper key, fetched on demand in a later phase; this
|
||||||
/// is the set that is worth importing before anything asks for it, because on this
|
/// is the set that is worth importing before anything asks for it, because on this
|
||||||
/// machine's client it is **787 sprites at about a kilobyte each**.
|
/// machine's client it is **1,022 sprites at about a kilobyte each** — 787 out of the
|
||||||
|
/// legacy `anim*.mul` files and, since phase 4, 235 more out of `AnimationFrame*.uop`,
|
||||||
|
/// which ServUO's vendored decoder never opens (§4.3, §4.9).
|
||||||
///
|
///
|
||||||
/// Two request kinds, which are §6's two stages for assets rather than for sources:
|
/// Two request kinds, which are §6's two stages for assets rather than for sources:
|
||||||
///
|
///
|
||||||
@@ -70,6 +72,17 @@ namespace Server.Custom.Bridge
|
|||||||
/// from the library's reused stream buffer. That is 357 of the 1,144 bodies the library
|
/// from the library's reused stream buffer. That is 357 of the 1,144 bodies the library
|
||||||
/// claims on a stock client, and importing them would have written 357 duplicate
|
/// claims on a stock client, and importing them would have written 357 duplicate
|
||||||
/// portraits whose subject depended on the order this walk happened to run in.
|
/// portraits whose subject depended on the order this walk happened to run in.
|
||||||
|
///
|
||||||
|
/// ── **The UOP fallback, and why it cannot reintroduce that** ──
|
||||||
|
///
|
||||||
|
/// Phase 4 added <see cref="BridgeUop"/> beneath the legacy reader: a body the vendored
|
||||||
|
/// path has nothing for is looked for in the UOP packages before it is reported absent.
|
||||||
|
/// That is where two of the six player-character bodies live — `Bodyconv.def` sends
|
||||||
|
/// gargoyles 666 and 667 to `anim5`, at an index past the end of `anim5.idx` — and 233
|
||||||
|
/// other bodies besides. It cannot produce a wrong picture the way a legacy sweep would,
|
||||||
|
/// because a UOP entry is addressed by the hash of a name that contains the body id and
|
||||||
|
/// the payload then declares that id again, which
|
||||||
|
/// <see cref="BridgeUop.Group.TryOpen"/> checks.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BridgeCatalog
|
public static class BridgeCatalog
|
||||||
{
|
{
|
||||||
@@ -103,6 +116,15 @@ namespace Server.Custom.Bridge
|
|||||||
public byte[] Png;
|
public byte[] Png;
|
||||||
public int Width;
|
public int Width;
|
||||||
public int Height;
|
public int Height;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which reader produced it: `legacy` for ServUO's vendored <c>Animations</c> over
|
||||||
|
/// <c>anim*.mul</c>, `uop` for phase 4's own reader over
|
||||||
|
/// <c>AnimationFrame*.uop</c> (§4.3, §4.9). On the wire so that an operator
|
||||||
|
/// looking at a wrong picture can tell which half of the extractor to doubt, and
|
||||||
|
/// so the acceptance walk can prove the fallback fired at all.
|
||||||
|
/// </summary>
|
||||||
|
public string Source;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class Catalog
|
private sealed class Catalog
|
||||||
@@ -243,6 +265,7 @@ namespace Server.Custom.Bridge
|
|||||||
item.Append(",\"height\":").Append(sprite.Height.ToString(CultureInfo.InvariantCulture));
|
item.Append(",\"height\":").Append(sprite.Height.ToString(CultureInfo.InvariantCulture));
|
||||||
item.Append(",\"body\":").Append(sprite.Body.ToString(CultureInfo.InvariantCulture));
|
item.Append(",\"body\":").Append(sprite.Body.ToString(CultureInfo.InvariantCulture));
|
||||||
item.Append(",\"direction\":").Append(sprite.Direction.ToString(CultureInfo.InvariantCulture));
|
item.Append(",\"direction\":").Append(sprite.Direction.ToString(CultureInfo.InvariantCulture));
|
||||||
|
item.Append(",\"source\":\"").Append(sprite.Source).Append('"');
|
||||||
item.Append('}');
|
item.Append('}');
|
||||||
|
|
||||||
if (!page.TryAdd(item.ToString(), "b:" + body.ToString(CultureInfo.InvariantCulture)))
|
if (!page.TryAdd(item.ToString(), "b:" + body.ToString(CultureInfo.InvariantCulture)))
|
||||||
@@ -446,6 +469,7 @@ namespace Server.Custom.Bridge
|
|||||||
item.Append(",\"height\":").Append(sprite.Height.ToString(CultureInfo.InvariantCulture));
|
item.Append(",\"height\":").Append(sprite.Height.ToString(CultureInfo.InvariantCulture));
|
||||||
item.Append(",\"body\":").Append(sprite.Body.ToString(CultureInfo.InvariantCulture));
|
item.Append(",\"body\":").Append(sprite.Body.ToString(CultureInfo.InvariantCulture));
|
||||||
item.Append(",\"direction\":").Append(sprite.Direction.ToString(CultureInfo.InvariantCulture));
|
item.Append(",\"direction\":").Append(sprite.Direction.ToString(CultureInfo.InvariantCulture));
|
||||||
|
item.Append(",\"source\":\"").Append(sprite.Source).Append('"');
|
||||||
item.Append(",\"png\":\"").Append(Convert.ToBase64String(sprite.Png)).Append("\"}");
|
item.Append(",\"png\":\"").Append(Convert.ToBase64String(sprite.Png)).Append("\"}");
|
||||||
|
|
||||||
return item.ToString();
|
return item.ToString();
|
||||||
@@ -474,6 +498,34 @@ namespace Server.Custom.Bridge
|
|||||||
? BridgeConfig.AssetPlayerDirection
|
? BridgeConfig.AssetPlayerDirection
|
||||||
: BridgeConfig.AssetCreatureDirection;
|
: BridgeConfig.AssetCreatureDirection;
|
||||||
|
|
||||||
|
// Legacy first, always. The vendored decoder is what 787 of this client's bodies come
|
||||||
|
// out of, it is what phase 3 measured, and the UOP packages hold a different and
|
||||||
|
// mostly disjoint set (measured: of the 244 bodies they carry, 8 also have legacy
|
||||||
|
// art). So this is a fallback rather than a choice, and no body changes reader while
|
||||||
|
// a client sits still.
|
||||||
|
Sprite sprite = ResolveLegacy(key, readers, body, direction)
|
||||||
|
?? ResolveUop(key, readers, body, direction);
|
||||||
|
|
||||||
|
if (sprite == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (!catalog.ByKey.ContainsKey(key))
|
||||||
|
{
|
||||||
|
catalog.ByKey[key] = sprite;
|
||||||
|
catalog.Order.Add(sprite);
|
||||||
|
}
|
||||||
|
|
||||||
|
return catalog.ByKey[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ServUO's vendored <c>Animations</c> over <c>anim*.mul</c>, behind §4.5's validator.
|
||||||
|
/// </summary>
|
||||||
|
private static Sprite ResolveLegacy(string key, Readers readers, int body, int direction)
|
||||||
|
{
|
||||||
int fileType, at;
|
int fileType, at;
|
||||||
string reason;
|
string reason;
|
||||||
|
|
||||||
@@ -505,11 +557,9 @@ namespace Server.Custom.Bridge
|
|||||||
if (!reader.AnimationSane(index, at, 1, out reason))
|
if (!reader.AnimationSane(index, at, 1, out reason))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
Sprite sprite;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
sprite = Decode(key, body, direction, fileType);
|
return Decode(key, body, direction, fileType);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
@@ -517,20 +567,93 @@ namespace Server.Custom.Bridge
|
|||||||
body, e.GetType().Name, e.Message);
|
body, e.GetType().Name, e.Message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (sprite == null)
|
/// <summary>
|
||||||
return null;
|
/// Phase 4's own reader over <c>AnimationFrame*.uop</c> (§4.3, §4.9), for the bodies
|
||||||
|
/// the legacy path has nothing for.
|
||||||
|
///
|
||||||
|
/// On this machine's client that is **235 bodies** the catalogue could not reach
|
||||||
|
/// before, including the two gargoyle player bodies — `Bodyconv.def` sends 666 and 667
|
||||||
|
/// to `anim5`, at an index past the end of `anim5.idx`, and the art has been in
|
||||||
|
/// `AnimationFrame3.uop` all along.
|
||||||
|
///
|
||||||
|
/// Nothing here can produce §4.8's failure. A legacy index is addressed by position,
|
||||||
|
/// so a wrong lookup is another creature's picture; a UOP entry is addressed by the
|
||||||
|
/// hash of a name carrying the body id, and the payload repeats that id in its own
|
||||||
|
/// header for <see cref="BridgeUop.Group.TryOpen"/> to check. A miss is a miss.
|
||||||
|
/// </summary>
|
||||||
|
private static Sprite ResolveUop(string key, Readers readers, int body, int direction)
|
||||||
|
{
|
||||||
|
ulong hash = BridgeUop.HashOf(body, CatalogAction);
|
||||||
|
|
||||||
lock (_sync)
|
byte[] payload = null;
|
||||||
|
string reason = null;
|
||||||
|
|
||||||
|
foreach (int n in BridgeUop.Packages)
|
||||||
{
|
{
|
||||||
if (!catalog.ByKey.ContainsKey(key))
|
BridgeUop.Package package = readers.Package(n);
|
||||||
|
|
||||||
|
if (package == null || !package.Has(hash))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!package.TryRead(hash, out payload, out reason))
|
||||||
{
|
{
|
||||||
catalog.ByKey[key] = sprite;
|
Console.WriteLine("[Bridge] catalogue: body {0} in {1}: {2}",
|
||||||
catalog.Order.Add(sprite);
|
body, BridgeUop.PackageName(n), reason);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return catalog.ByKey[key];
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
BridgeUop.Group group;
|
||||||
|
|
||||||
|
if (!BridgeUop.Group.TryOpen(payload, body, out group, out reason))
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] catalogue: body {0} uop: {1}", body, reason);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int frame = group.DirectionAt(direction);
|
||||||
|
|
||||||
|
if (frame < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
BridgeUop.Pixels pixels;
|
||||||
|
bool empty;
|
||||||
|
|
||||||
|
if (!group.TryDecode(frame, out pixels, out empty, out reason))
|
||||||
|
{
|
||||||
|
// A 0x0 frame is no art rather than damage — the vendored decoder returns early on
|
||||||
|
// exactly the same condition — so it is absent, silently. Anything else is a
|
||||||
|
// record this reader refused, and that is worth a line.
|
||||||
|
if (!empty)
|
||||||
|
Console.WriteLine("[Bridge] catalogue: body {0} uop: {1}", body, reason);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] png = BridgePng.FromArgb1555(pixels.Argb1555, pixels.Width, pixels.Height);
|
||||||
|
|
||||||
|
if (png == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new Sprite
|
||||||
|
{
|
||||||
|
Key = key,
|
||||||
|
Body = body,
|
||||||
|
Direction = direction,
|
||||||
|
FileType = 0,
|
||||||
|
Png = png,
|
||||||
|
Width = pixels.Width,
|
||||||
|
Height = pixels.Height,
|
||||||
|
Sha256 = Hash(png),
|
||||||
|
Source = "uop"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Sprite Decode(string key, int body, int direction, int fileType)
|
private static Sprite Decode(string key, int body, int direction, int fileType)
|
||||||
@@ -564,7 +687,8 @@ namespace Server.Custom.Bridge
|
|||||||
Png = png,
|
Png = png,
|
||||||
Width = bitmap.Width,
|
Width = bitmap.Width,
|
||||||
Height = bitmap.Height,
|
Height = bitmap.Height,
|
||||||
Sha256 = Hash(png)
|
Sha256 = Hash(png),
|
||||||
|
Source = "legacy"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,21 +798,32 @@ namespace Server.Custom.Bridge
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Asked of the shard, never hardcoded.
|
/// Asked of the shard, never hardcoded.
|
||||||
///
|
///
|
||||||
/// Every registered race carries four body ids, and a shard that calls `RegisterRace`
|
/// Every registered race carries its living male and female body ids, and a shard that
|
||||||
/// adds ids no table of ours could contain. Even on stock ServUO a hardcoded list
|
/// calls `RegisterRace` adds ids no table of ours could contain — which is the whole
|
||||||
/// would already be wrong in a way that is easy to miss: `RaceDefinitions.cs` passes
|
/// argument against a hardcoded list, and it was never hypothetical: stock ServUO's
|
||||||
/// the gargoyle's ghost bodies in the OPPOSITE order to the other two races.
|
/// own `RaceDefinitions.cs` passes the gargoyle's ghost bodies in the OPPOSITE order
|
||||||
|
/// to the other two races.
|
||||||
///
|
///
|
||||||
/// This set is the whole of what §5.1 gives direction 0 — head-on, facing the viewer,
|
/// This set is the whole of what §5.1 gives direction 0 — head-on, facing the viewer,
|
||||||
/// because a character is a portrait and should look at you. Everything else takes
|
/// because a character is a portrait and should look at you. Everything else takes
|
||||||
/// direction 1, the front three-quarter, because head-on is the least legible view of
|
/// direction 1, the front three-quarter, because head-on is the least legible view of
|
||||||
/// a four-legged creature: a wolf seen from the front is a dark blob.
|
/// a four-legged creature: a wolf seen from the front is a dark blob.
|
||||||
|
///
|
||||||
|
/// **Ghost bodies are deliberately not in it** (§5.2, decided 2026-09-10 in phase 4).
|
||||||
|
/// A race declares four ids and two of them are its ghosts, and no UO client has art
|
||||||
|
/// for any of them: 402/403 and 694/695 read `lookup -1` in `anim.idx`, 607/608 read
|
||||||
|
/// `length 0` — the §4.8 shape, where the library hands back the previously-decoded
|
||||||
|
/// body's picture — and none of the six is in any `AnimationFrame*.uop`, which phase 4
|
||||||
|
/// established by claiming all 10,724 entries of the five packages with the one name
|
||||||
|
/// scheme. Listing them only advertised keys that cannot exist. A shard whose client
|
||||||
|
/// does ship ghost art still gets it: the body is catalogued like any other, at
|
||||||
|
/// direction 1 rather than 0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cached for the life of the process: `RegisterRace` runs at Configure time, before
|
/// Cached for the life of the process: `RegisterRace` runs at Configure time, before
|
||||||
/// anything on this plane can be asked a question, so the set cannot change under a
|
/// anything on this plane can be asked a question, so the set cannot change under a
|
||||||
/// running shard. Rebuilding it per body would enumerate every race 2,047 times per
|
/// running shard. Rebuilding it per body would enumerate every race 2,047 times per
|
||||||
/// scan to answer a question whose answer is twelve integers.
|
/// scan to answer a question whose answer is six integers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static HashSet<int> _playerBodies;
|
private static HashSet<int> _playerBodies;
|
||||||
|
|
||||||
@@ -710,8 +845,6 @@ namespace Server.Custom.Bridge
|
|||||||
|
|
||||||
set.Add(race.MaleBody);
|
set.Add(race.MaleBody);
|
||||||
set.Add(race.FemaleBody);
|
set.Add(race.FemaleBody);
|
||||||
set.Add(race.MaleGhostBody);
|
|
||||||
set.Add(race.FemaleGhostBody);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
@@ -829,12 +962,22 @@ namespace Server.Custom.Bridge
|
|||||||
.Append(':').Append(BridgeConfig.AssetPlayerDirection)
|
.Append(':').Append(BridgeConfig.AssetPlayerDirection)
|
||||||
.Append(':').Append(BridgeConfig.AssetCreatureDirection);
|
.Append(':').Append(BridgeConfig.AssetCreatureDirection);
|
||||||
|
|
||||||
|
var paths = new List<string>();
|
||||||
|
|
||||||
for (int fileType = 1; fileType <= 5; fileType++)
|
for (int fileType = 1; fileType <= 5; fileType++)
|
||||||
|
paths.Add(BridgeAssetValidator.AnimDataPath(fileType));
|
||||||
|
|
||||||
|
// Since phase 4 the catalogue's bytes depend on the UOP packages too — 235 of its
|
||||||
|
// bodies come out of them — so patching one has to change the catalogue id, exactly as
|
||||||
|
// patching an anim*.mul does. Leaving them out would let an operator replace a
|
||||||
|
// gargoyle and have an Update find nothing to do.
|
||||||
|
foreach (int n in BridgeUop.Packages)
|
||||||
|
paths.Add(BridgeUop.PackagePath(n));
|
||||||
|
|
||||||
|
foreach (string path in paths)
|
||||||
{
|
{
|
||||||
sb.Append('|');
|
sb.Append('|');
|
||||||
|
|
||||||
string path = BridgeAssetValidator.AnimDataPath(fileType);
|
|
||||||
|
|
||||||
if (path == null)
|
if (path == null)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -883,10 +1026,11 @@ namespace Server.Custom.Bridge
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The five anim files' index and record readers, opened for one reply and closed with
|
/// The five anim files' index and record readers — and, since phase 4, the five UOP
|
||||||
/// it. Holding them across replies would keep handles on the operator's client files
|
/// packages beside them — opened for one reply and closed with it. Holding them across
|
||||||
/// for as long as the cache lives, for no gain: opening five is microseconds and a
|
/// replies would keep handles on the operator's client files for as long as the cache
|
||||||
/// page decodes hundreds of sprites through them.
|
/// lives, for no gain: opening them is microseconds and a page decodes hundreds of
|
||||||
|
/// sprites through them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class Readers : IDisposable
|
private sealed class Readers : IDisposable
|
||||||
{
|
{
|
||||||
@@ -896,6 +1040,9 @@ namespace Server.Custom.Bridge
|
|||||||
private readonly long[] _length = new long[6];
|
private readonly long[] _length = new long[6];
|
||||||
private readonly bool[] _open = new bool[6];
|
private readonly bool[] _open = new bool[6];
|
||||||
|
|
||||||
|
private readonly Dictionary<int, BridgeUop.Package> _packages =
|
||||||
|
new Dictionary<int, BridgeUop.Package>();
|
||||||
|
|
||||||
public readonly long VerdataLength;
|
public readonly long VerdataLength;
|
||||||
|
|
||||||
public Readers()
|
public Readers()
|
||||||
@@ -958,8 +1105,42 @@ namespace Server.Custom.Bridge
|
|||||||
return fileType >= 1 && fileType <= 5 ? _reader[fileType] : null;
|
return fileType >= 1 && fileType <= 5 ? _reader[fileType] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One <c>AnimationFrame*.uop</c>, opened on first use. A package this client does
|
||||||
|
/// not ship is a null that is cached as one: the miss must not be re-resolved and
|
||||||
|
/// re-opened once per body across a 2,047-body walk.
|
||||||
|
/// </summary>
|
||||||
|
public BridgeUop.Package Package(int n)
|
||||||
|
{
|
||||||
|
BridgeUop.Package package;
|
||||||
|
|
||||||
|
if (_packages.TryGetValue(n, out package))
|
||||||
|
return package;
|
||||||
|
|
||||||
|
package = BridgeUop.Package.Open(BridgeUop.PackagePath(n));
|
||||||
|
|
||||||
|
_packages[n] = package;
|
||||||
|
|
||||||
|
return package;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
foreach (var package in _packages.Values)
|
||||||
|
{
|
||||||
|
if (package == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
package.Dispose();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Closing a read-only handle. Nothing useful is left to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = 1; i <= 5; i++)
|
for (int i = 1; i <= 5; i++)
|
||||||
{
|
{
|
||||||
if (_reader[i] == null)
|
if (_reader[i] == null)
|
||||||
|
|||||||
231
overlay/Scripts/Custom/Bridge/BridgePng.cs
Normal file
231
overlay/Scripts/Custom/Bridge/BridgePng.cs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// **A PNG encoder that does not go through GDI+** (docs/link/v8.md §4.4, §4.9 — phase 4).
|
||||||
|
///
|
||||||
|
/// <see cref="BridgeUop"/> decodes into a <c>ushort[]</c> of ARGB1555 rather than into a
|
||||||
|
/// <c>Bitmap</c>, which is the whole point of §4.4's note that the UOP reader is written
|
||||||
|
/// without <c>System.Drawing</c>: libgdiplus was archived in March 2025, and every line of
|
||||||
|
/// extraction that does not depend on it is a line that survives its absence. That leaves
|
||||||
|
/// the encode, and <c>Bitmap.Save(…, ImageFormat.Png)</c> is GDI+ too — so this is the
|
||||||
|
/// other half.
|
||||||
|
///
|
||||||
|
/// It is deliberately the smallest thing that produces a correct file: 8-bit RGBA, one
|
||||||
|
/// IDAT, filter type 0 on every row. No interlacing, no palette, no colour-type choice, no
|
||||||
|
/// filter heuristics. A sprite is a few hundred pixels across and the bytes go straight
|
||||||
|
/// into a base64 field; the compression difference between this and a tuned encoder is a
|
||||||
|
/// rounding error against the wire, and every knob not turned is a way this cannot be
|
||||||
|
/// subtly wrong.
|
||||||
|
///
|
||||||
|
/// Phase 3's <c>BridgeCatalog.ToPng</c> is left exactly as it is. It is measured, shipped,
|
||||||
|
/// and its input really is a <c>Bitmap</c> from the vendored decoder — a path that needs
|
||||||
|
/// GDI+ to produce the pixels in the first place, so encoding them without it buys nothing.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgePng
|
||||||
|
{
|
||||||
|
private static readonly byte[] Signature =
|
||||||
|
{
|
||||||
|
0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly uint[] CrcTable = BuildCrcTable();
|
||||||
|
|
||||||
|
private static readonly byte[] Empty = new byte[0];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ARGB1555 to an RGBA8 PNG with a transparent background.
|
||||||
|
///
|
||||||
|
/// The expansion is the same one <c>BridgeCatalog.ToPng</c> documents and for the same
|
||||||
|
/// reason: alpha bit clear is fully transparent, and each 5-bit channel is widened by
|
||||||
|
/// repeating its high bits — <c>(c << 3) | (c >> 2)</c>, not a plain shift,
|
||||||
|
/// which would cap white at 248 and tint every sprite.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] FromArgb1555(ushort[] pixels, int width, int height)
|
||||||
|
{
|
||||||
|
if (pixels == null || width <= 0 || height <= 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if ((long)width * height > pixels.Length)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// One filter byte per row, then RGBA per pixel. This is the PNG "raw" stream, the
|
||||||
|
// thing that gets deflated. Bounded by the caller's dimension ceiling
|
||||||
|
// (BridgeAssetValidator.MaxArtDimension), so the arithmetic cannot overflow an int —
|
||||||
|
// the check is here anyway, because that ceiling lives in another file.
|
||||||
|
long size = (((long)width * 4) + 1) * height;
|
||||||
|
|
||||||
|
if (size > Int32.MaxValue / 2)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var raw = new byte[size];
|
||||||
|
|
||||||
|
int at = 0;
|
||||||
|
|
||||||
|
for (int y = 0; y < height; y++)
|
||||||
|
{
|
||||||
|
raw[at++] = 0; // filter: None
|
||||||
|
|
||||||
|
int row = y * width;
|
||||||
|
|
||||||
|
for (int x = 0; x < width; x++)
|
||||||
|
{
|
||||||
|
int p = pixels[row + x];
|
||||||
|
|
||||||
|
if ((p & 0x8000) == 0)
|
||||||
|
{
|
||||||
|
at += 4; // already zero: transparent black
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int r = (p >> 10) & 0x1F;
|
||||||
|
int g = (p >> 5) & 0x1F;
|
||||||
|
int b = p & 0x1F;
|
||||||
|
|
||||||
|
raw[at++] = (byte)((r << 3) | (r >> 2));
|
||||||
|
raw[at++] = (byte)((g << 3) | (g >> 2));
|
||||||
|
raw[at++] = (byte)((b << 3) | (b >> 2));
|
||||||
|
raw[at++] = 0xFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var ms = new MemoryStream(raw.Length / 2))
|
||||||
|
{
|
||||||
|
ms.Write(Signature, 0, Signature.Length);
|
||||||
|
|
||||||
|
var header = new byte[13];
|
||||||
|
|
||||||
|
WriteBigEndian(header, 0, (uint)width);
|
||||||
|
WriteBigEndian(header, 4, (uint)height);
|
||||||
|
|
||||||
|
header[8] = 8; // bit depth
|
||||||
|
header[9] = 6; // colour type: truecolour with alpha
|
||||||
|
header[10] = 0; // compression: deflate
|
||||||
|
header[11] = 0; // filter method 0
|
||||||
|
header[12] = 0; // no interlace
|
||||||
|
|
||||||
|
WriteChunk(ms, "IHDR", header, 0, header.Length);
|
||||||
|
|
||||||
|
byte[] deflated = Zlib(raw);
|
||||||
|
|
||||||
|
WriteChunk(ms, "IDAT", deflated, 0, deflated.Length);
|
||||||
|
WriteChunk(ms, "IEND", Empty, 0, 0);
|
||||||
|
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A zlib stream around .NET Framework's raw-deflate-only <c>DeflateStream</c>: the
|
||||||
|
/// two-byte header PNG requires, the deflate data, and the adler32 trailer computed
|
||||||
|
/// here because nothing in the framework will do it. Written by hand for exactly the
|
||||||
|
/// same reason <see cref="BridgeUop"/> reads one by hand — net48 exposes deflate and
|
||||||
|
/// calls it zlib, and the two are not the same format.
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] Zlib(byte[] data)
|
||||||
|
{
|
||||||
|
using (var ms = new MemoryStream(data.Length / 2))
|
||||||
|
{
|
||||||
|
// CMF 0x78 (deflate, 32K window) and FLG 0x9C (default level, no dictionary):
|
||||||
|
// 0x789C is the pair whose value is divisible by 31, which is the check a decoder
|
||||||
|
// applies.
|
||||||
|
ms.WriteByte(0x78);
|
||||||
|
ms.WriteByte(0x9C);
|
||||||
|
|
||||||
|
using (var deflate = new DeflateStream(ms, CompressionMode.Compress, true))
|
||||||
|
deflate.Write(data, 0, data.Length);
|
||||||
|
|
||||||
|
uint adler = Adler32(data);
|
||||||
|
|
||||||
|
ms.WriteByte((byte)(adler >> 24));
|
||||||
|
ms.WriteByte((byte)(adler >> 16));
|
||||||
|
ms.WriteByte((byte)(adler >> 8));
|
||||||
|
ms.WriteByte((byte)adler);
|
||||||
|
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteChunk(Stream to, string type, byte[] data, int offset, int length)
|
||||||
|
{
|
||||||
|
var head = new byte[8];
|
||||||
|
|
||||||
|
WriteBigEndian(head, 0, (uint)length);
|
||||||
|
|
||||||
|
head[4] = (byte)type[0];
|
||||||
|
head[5] = (byte)type[1];
|
||||||
|
head[6] = (byte)type[2];
|
||||||
|
head[7] = (byte)type[3];
|
||||||
|
|
||||||
|
to.Write(head, 0, head.Length);
|
||||||
|
|
||||||
|
if (length > 0)
|
||||||
|
to.Write(data, offset, length);
|
||||||
|
|
||||||
|
// The CRC covers the type and the data, and not the length.
|
||||||
|
uint crc = Crc32(head, 4, 4, 0xFFFFFFFF);
|
||||||
|
|
||||||
|
if (length > 0)
|
||||||
|
crc = Crc32(data, offset, length, crc);
|
||||||
|
|
||||||
|
crc ^= 0xFFFFFFFF;
|
||||||
|
|
||||||
|
var tail = new byte[4];
|
||||||
|
|
||||||
|
WriteBigEndian(tail, 0, crc);
|
||||||
|
|
||||||
|
to.Write(tail, 0, tail.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteBigEndian(byte[] into, int at, uint value)
|
||||||
|
{
|
||||||
|
into[at] = (byte)(value >> 24);
|
||||||
|
into[at + 1] = (byte)(value >> 16);
|
||||||
|
into[at + 2] = (byte)(value >> 8);
|
||||||
|
into[at + 3] = (byte)value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint[] BuildCrcTable()
|
||||||
|
{
|
||||||
|
var table = new uint[256];
|
||||||
|
|
||||||
|
for (uint n = 0; n < 256; n++)
|
||||||
|
{
|
||||||
|
uint c = n;
|
||||||
|
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1;
|
||||||
|
|
||||||
|
table[n] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint Crc32(byte[] data, int offset, int length, uint crc)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < length; i++)
|
||||||
|
crc = CrcTable[(crc ^ data[offset + i]) & 0xFF] ^ (crc >> 8);
|
||||||
|
|
||||||
|
return crc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint Adler32(byte[] data)
|
||||||
|
{
|
||||||
|
const uint Mod = 65521;
|
||||||
|
|
||||||
|
uint a = 1, b = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < data.Length; i++)
|
||||||
|
{
|
||||||
|
a = (a + data[i]) % Mod;
|
||||||
|
b = (b + a) % Mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (b << 16) | a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
823
overlay/Scripts/Custom/Bridge/BridgeUop.cs
Normal file
823
overlay/Scripts/Custom/Bridge/BridgeUop.cs
Normal file
@@ -0,0 +1,823 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using Ultima;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// **The UOP animation reader** (docs/link/v8.md §4.3, §4.9 — protocol 8, phase 4): the
|
||||||
|
/// second and last decoder this protocol writes rather than calls.
|
||||||
|
///
|
||||||
|
/// ServUO's vendored <c>Ultima.Animations</c> reads legacy <c>anim*.mul</c> only — it
|
||||||
|
/// constructs its five <c>FileIndex</c>es with the four-argument constructor, which passes
|
||||||
|
/// <c>uopFile: null</c>, so <c>AnimationFrame*.uop</c> is never opened. Everything a
|
||||||
|
/// modern client added there is invisible to it. This class opens those packages directly.
|
||||||
|
///
|
||||||
|
/// ── **Why this is not the never-sweep rule being broken** ──
|
||||||
|
///
|
||||||
|
/// §4.3's rule is that a body's file type comes from <c>BodyConverter.Convert</c> and is
|
||||||
|
/// never guessed, because asking another <c>anim*.mul</c> for an index it does not own
|
||||||
|
/// returns a decodable picture of something else — a giant spider on the gargoyle page.
|
||||||
|
/// That rule exists because a legacy index is addressed **by position**: nothing in the
|
||||||
|
/// file says which body a record belongs to.
|
||||||
|
///
|
||||||
|
/// A UOP package is addressed by the **hash of a name that contains the body id**
|
||||||
|
/// (<c>build/animationlegacyframe/000666/00.bin</c>). Looking in all five packages for one
|
||||||
|
/// hash is therefore not a sweep — a hit is proof of identity, not a coincidence of
|
||||||
|
/// position, and the payload repeats the body id in its own header for us to check against.
|
||||||
|
/// Measured on this machine's client: 10,724 entries across the five packages, every one
|
||||||
|
/// of them claimed by that name scheme, and **no hash appears in more than one package**.
|
||||||
|
///
|
||||||
|
/// ── **Validate as we go, because here we are the library** ──
|
||||||
|
///
|
||||||
|
/// §4.5's rule is "validate before calling", and it exists because <c>Ultima</c>'s decoders
|
||||||
|
/// take their bounds from the file they are reading. Nothing about this code can be
|
||||||
|
/// validated from outside — it *is* the decode — so the same discipline appears as a bound
|
||||||
|
/// on every read: the block chain against the file length, an entry's record against the
|
||||||
|
/// file, the inflated length against the declared one, the frame table against the
|
||||||
|
/// payload, and every run header against **both** the record's remaining bytes and the
|
||||||
|
/// bitmap it is writing into. A record that fails any of them is reported absent and no
|
||||||
|
/// pixel of it is kept.
|
||||||
|
///
|
||||||
|
/// Measured the same way §4.5 was, which is the only measurement that says the boundary is
|
||||||
|
/// in the right place: across every UOP body on a stock client the walk refused **nothing**
|
||||||
|
/// that carries art, and the one body it does refuse (286) declares a 0×0 frame, which the
|
||||||
|
/// legacy decoder treats as absent too.
|
||||||
|
///
|
||||||
|
/// ── **No <c>System.Drawing</c>, deliberately** ──
|
||||||
|
///
|
||||||
|
/// §4.4 states it: libgdiplus was archived in March 2025, and the long-term argument for
|
||||||
|
/// moving extraction off <c>System.Drawing</c> is that a Linux shard depends on an
|
||||||
|
/// unmaintained library to see a sprite. This decoder writes ARGB1555 into a
|
||||||
|
/// <c>ushort[]</c> of its own and <see cref="BridgePng"/> encodes that directly, so the
|
||||||
|
/// door stays open. (Phase 4 does not walk through it: the catalogue still refuses the
|
||||||
|
/// whole family when imaging is unavailable, because most of it genuinely needs GDI+.)
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeUop
|
||||||
|
{
|
||||||
|
/// <summary>'MYP\0' — the Mythic package magic, as <c>FileIndex</c> reads it.</summary>
|
||||||
|
private const int PackageMagic = 0x50594D;
|
||||||
|
|
||||||
|
/// <summary>'AMOU' — the animation payload's own magic, little-endian.</summary>
|
||||||
|
private const int PayloadMagic = 0x554F4D41;
|
||||||
|
|
||||||
|
/// <summary>Each frame record opens with its own palette: 0x100 ARGB1555 entries.</summary>
|
||||||
|
private const int PaletteBytes = 0x100 * 2;
|
||||||
|
|
||||||
|
/// <summary>The frame table's row width: group, frame id, two unknowns, pixel offset.</summary>
|
||||||
|
private const int FrameRowBytes = 16;
|
||||||
|
|
||||||
|
/// <summary>One block-chain record: offset, three lengths, hash, adler32, flag.</summary>
|
||||||
|
private const int BlockEntryBytes = 34;
|
||||||
|
|
||||||
|
/// <summary>The xor <c>Frame</c> applies to every run header, and so must this.</summary>
|
||||||
|
private const int DoubleXor = (0x200 << 22) | (0x200 << 12);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A ceiling on a declared decompressed payload. One group file is a whole action for
|
||||||
|
/// one body across every direction; the largest on this machine's client is body
|
||||||
|
/// 1248's at 4.3 MB, so this is two orders of magnitude of headroom over real data and
|
||||||
|
/// still small enough that a corrupt length cannot ask for the host's memory.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxPayloadBytes = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A ceiling on the block chain. Five packages hold 10,724 entries between them; this
|
||||||
|
/// bounds a cyclic or corrupt chain into a refusal rather than a hang.
|
||||||
|
/// </summary>
|
||||||
|
private const int MaxEntries = 1 << 20;
|
||||||
|
|
||||||
|
/// <summary>The five packages this client ships. There is no AnimationFrame5.uop.</summary>
|
||||||
|
private static readonly int[] PackageNumbers = { 1, 2, 3, 4, 6 };
|
||||||
|
|
||||||
|
public static IEnumerable<int> Packages
|
||||||
|
{
|
||||||
|
get { return PackageNumbers; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string PackageName(int n)
|
||||||
|
{
|
||||||
|
return "AnimationFrame" + n.ToString(CultureInfo.InvariantCulture) + ".uop";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a UOP animation package lives.
|
||||||
|
///
|
||||||
|
/// <c>Ultima.Files.GetFilePath</c> cannot answer this: its table of known client files
|
||||||
|
/// predates UOP animations and contains no <c>AnimationFrame*.uop</c> entry, so it
|
||||||
|
/// returns null for every one of them. So the lookup is done here, against the same
|
||||||
|
/// directories ServUO itself resolved at boot — <c>Files.RootDir</c> first, then
|
||||||
|
/// <c>Core.DataDirectories</c>, which §1 is built on.
|
||||||
|
///
|
||||||
|
/// The comparison is case-insensitive **by enumeration** rather than by trying one
|
||||||
|
/// spelling. On Windows either would work; on a Linux shard host the client directory
|
||||||
|
/// is case-sensitive and the file may be shipped as `AnimationFrame1.uop`,
|
||||||
|
/// `animationframe1.uop` or anything between, which is exactly the shape of bug that
|
||||||
|
/// presents as "the gargoyles import on my machine and not on the server".
|
||||||
|
///
|
||||||
|
/// <see cref="FindClientFile"/> is the general form, and `assets.sources` uses it for
|
||||||
|
/// the same reason: a file Ultima's table predates has to be found some other way.
|
||||||
|
/// </summary>
|
||||||
|
public static string PackagePath(int n)
|
||||||
|
{
|
||||||
|
return FindClientFile(PackageName(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly object _pathSync = new object();
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> _paths =
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds a client file <c>Ultima.Files</c> has never heard of.
|
||||||
|
///
|
||||||
|
/// Only successful answers are cached: a file an operator copies in while the shard is
|
||||||
|
/// up should be found by the next import, and nothing here is hot enough for a
|
||||||
|
/// negative cache to be worth that.
|
||||||
|
/// </summary>
|
||||||
|
public static string FindClientFile(string name)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(name))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
lock (_pathSync)
|
||||||
|
{
|
||||||
|
string cached;
|
||||||
|
|
||||||
|
if (_paths.TryGetValue(name, out cached))
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string dir in Directories())
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(dir))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(dir))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
string direct = Path.Combine(dir, name);
|
||||||
|
string hit = File.Exists(direct) ? direct : null;
|
||||||
|
|
||||||
|
if (hit == null)
|
||||||
|
{
|
||||||
|
foreach (string found in Directory.GetFiles(dir))
|
||||||
|
{
|
||||||
|
if (String.Equals(Path.GetFileName(found), name,
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
hit = found;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hit == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
lock (_pathSync)
|
||||||
|
_paths[name] = hit;
|
||||||
|
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] uop: cannot look in {0}: {1}", dir, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> Directories()
|
||||||
|
{
|
||||||
|
string root = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
root = Files.RootDir;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ultima's static initialiser reads the registry on Windows. A host where that
|
||||||
|
// throws still has Core.DataDirectories, which is the path ServUO actually booted
|
||||||
|
// from.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!String.IsNullOrEmpty(root))
|
||||||
|
yield return root;
|
||||||
|
|
||||||
|
List<string> dirs = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dirs = Core.DataDirectories;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Same reasoning; an empty list is a real answer and the caller reports absent.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirs == null)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
foreach (string dir in dirs)
|
||||||
|
yield return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name a body's action file is stored under, hashed the way the container indexes
|
||||||
|
/// it. <c>Ultima.FileIndex.HashFileName</c> is pure arithmetic over a string — no file
|
||||||
|
/// is touched and no decoder is entered — so this is the one place phase 4 leans on
|
||||||
|
/// the vendored code, and it leans on it precisely so that our lookup cannot disagree
|
||||||
|
/// with the container's own.
|
||||||
|
/// </summary>
|
||||||
|
public static ulong HashOf(int body, int action)
|
||||||
|
{
|
||||||
|
string name = String.Format(CultureInfo.InvariantCulture,
|
||||||
|
"build/animationlegacyframe/{0:D6}/{1:D2}.bin", body, action);
|
||||||
|
|
||||||
|
return FileIndex.HashFileName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the container ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private struct Entry
|
||||||
|
{
|
||||||
|
public long At;
|
||||||
|
public int CompressedLength;
|
||||||
|
public int DecompressedLength;
|
||||||
|
public short Flag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One opened <c>AnimationFrame*.uop</c>: its entry table in memory, its bytes on
|
||||||
|
/// demand. Opening one is a single pass over the block chain — 10,724 entries across
|
||||||
|
/// all five on this client — and the handle is held for the life of a reply, exactly
|
||||||
|
/// like the legacy readers next to it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Package : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Dictionary<ulong, Entry> _entries;
|
||||||
|
private readonly FileStream _stream;
|
||||||
|
|
||||||
|
public readonly string Path;
|
||||||
|
|
||||||
|
private Package(string path, FileStream stream, Dictionary<ulong, Entry> entries)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
_stream = stream;
|
||||||
|
_entries = entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Count
|
||||||
|
{
|
||||||
|
get { return _entries.Count; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the block chain, refusing anything that does not fit inside the file.
|
||||||
|
/// Returns null — never throws — because a client that ships a truncated package
|
||||||
|
/// is an ordinary thing to survive, not an error to raise.
|
||||||
|
/// </summary>
|
||||||
|
public static Package Open(string path)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(path))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
FileStream stream = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
stream = new FileStream(path, FileMode.Open, FileAccess.Read,
|
||||||
|
FileShare.ReadWrite);
|
||||||
|
|
||||||
|
long length = stream.Length;
|
||||||
|
|
||||||
|
var entries = new Dictionary<ulong, Entry>();
|
||||||
|
|
||||||
|
using (var br = new BinaryReader(stream, Encoding.UTF8, true))
|
||||||
|
{
|
||||||
|
if (length < 28 || br.ReadInt32() != PackageMagic)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] uop: {0} is not a Mythic package", path);
|
||||||
|
stream.Dispose();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
br.ReadInt32(); // version
|
||||||
|
br.ReadUInt32(); // signature
|
||||||
|
|
||||||
|
long nextBlock = br.ReadInt64();
|
||||||
|
|
||||||
|
br.ReadInt32(); // block capacity
|
||||||
|
br.ReadInt32(); // declared file count
|
||||||
|
|
||||||
|
while (nextBlock > 0)
|
||||||
|
{
|
||||||
|
// A block header is 12 bytes. Anything that does not leave room for
|
||||||
|
// one is a corrupt or cyclic chain, and this is where it stops.
|
||||||
|
if (nextBlock + 12 > length)
|
||||||
|
break;
|
||||||
|
|
||||||
|
stream.Seek(nextBlock, SeekOrigin.Begin);
|
||||||
|
|
||||||
|
int filesCount = br.ReadInt32();
|
||||||
|
long following = br.ReadInt64();
|
||||||
|
|
||||||
|
if (filesCount < 0
|
||||||
|
|| nextBlock + 12 + ((long)filesCount * BlockEntryBytes) > length)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < filesCount; i++)
|
||||||
|
{
|
||||||
|
long offset = br.ReadInt64();
|
||||||
|
int headerLength = br.ReadInt32();
|
||||||
|
int compressedLength = br.ReadInt32();
|
||||||
|
int decompressedLength = br.ReadInt32();
|
||||||
|
ulong hash = br.ReadUInt64();
|
||||||
|
|
||||||
|
br.ReadUInt32(); // adler32
|
||||||
|
|
||||||
|
short flag = br.ReadInt16();
|
||||||
|
|
||||||
|
if (offset <= 0 || headerLength < 0 || compressedLength <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (decompressedLength <= 0 || decompressedLength > MaxPayloadBytes)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
long at = offset + headerLength;
|
||||||
|
|
||||||
|
// The check FileIndex.Seek is missing, in the place it matters
|
||||||
|
// here too: that the record ENDS inside the file, not merely that
|
||||||
|
// it starts inside it (§4.5).
|
||||||
|
if (at < 0 || at + compressedLength > length)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (entries.Count >= MaxEntries)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// First writer wins. Nothing on this client produces a collision
|
||||||
|
// — measured: no hash appears in two packages, and none twice in
|
||||||
|
// one — and if a patched client ever did, taking the first is the
|
||||||
|
// answer that does not depend on chain order.
|
||||||
|
if (!entries.ContainsKey(hash))
|
||||||
|
entries[hash] = new Entry
|
||||||
|
{
|
||||||
|
At = at,
|
||||||
|
CompressedLength = compressedLength,
|
||||||
|
DecompressedLength = decompressedLength,
|
||||||
|
Flag = flag
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (following <= nextBlock)
|
||||||
|
break; // a chain that does not move forward is a loop
|
||||||
|
|
||||||
|
nextBlock = following;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Package(path, stream, entries);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] uop: cannot open {0}: {1}: {2}",
|
||||||
|
path, e.GetType().Name, e.Message);
|
||||||
|
|
||||||
|
if (stream != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
stream.Dispose();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Closing a read-only handle.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Has(ulong hash)
|
||||||
|
{
|
||||||
|
return _entries.ContainsKey(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The bytes behind one entry, decompressed. False with a reason is the ordinary
|
||||||
|
/// answer for "this package does not hold it".
|
||||||
|
/// </summary>
|
||||||
|
public bool TryRead(ulong hash, out byte[] payload, out string reason)
|
||||||
|
{
|
||||||
|
payload = null;
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
Entry entry;
|
||||||
|
|
||||||
|
if (!_entries.TryGetValue(hash, out entry))
|
||||||
|
{
|
||||||
|
reason = "not in " + System.IO.Path.GetFileName(Path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] raw;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_stream.Seek(entry.At, SeekOrigin.Begin);
|
||||||
|
|
||||||
|
raw = new byte[entry.CompressedLength];
|
||||||
|
|
||||||
|
if (!Fill(_stream, raw, raw.Length))
|
||||||
|
{
|
||||||
|
// The §4.5 failure, in our own code this time: a short read that nobody
|
||||||
|
// checked is how the library ends up decoding the previous asset.
|
||||||
|
reason = "record is shorter than the index claims";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
reason = "read failed: " + e.GetType().Name;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Flag != 1)
|
||||||
|
{
|
||||||
|
if (raw.Length != entry.DecompressedLength)
|
||||||
|
{
|
||||||
|
reason = "stored record is " + raw.Length + " bytes, not the declared "
|
||||||
|
+ entry.DecompressedLength;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = raw;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryInflate(raw, entry.DecompressedLength, out payload, out reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_stream.Dispose();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Closing a read-only handle. Nothing useful is left to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Fill(Stream stream, byte[] into, int count)
|
||||||
|
{
|
||||||
|
int read = 0;
|
||||||
|
|
||||||
|
while (read < count)
|
||||||
|
{
|
||||||
|
int n = stream.Read(into, read, count - read);
|
||||||
|
|
||||||
|
if (n <= 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
read += n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// zlib, which .NET Framework 4.8 does not expose — only raw deflate. The two-byte
|
||||||
|
/// zlib header is checked and skipped rather than assumed, because handing a
|
||||||
|
/// <c>DeflateStream</c> a stream that is not deflate produces garbage as readily as an
|
||||||
|
/// exception, and the trailing adler32 is left to the length check below: a stream
|
||||||
|
/// that inflates to exactly the declared number of bytes did not silently truncate.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryInflate(byte[] raw, int declared, out byte[] payload, out string reason)
|
||||||
|
{
|
||||||
|
payload = null;
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
if (raw.Length < 3)
|
||||||
|
{
|
||||||
|
reason = "compressed record is too short to be zlib";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cmf = raw[0];
|
||||||
|
int flg = raw[1];
|
||||||
|
|
||||||
|
if ((cmf & 0x0F) != 8 || (((cmf << 8) + flg) % 31) != 0 || (flg & 0x20) != 0)
|
||||||
|
{
|
||||||
|
reason = "compressed record is not a zlib stream";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var output = new byte[declared];
|
||||||
|
|
||||||
|
using (var source = new MemoryStream(raw, 2, raw.Length - 2, false))
|
||||||
|
using (var inflate = new DeflateStream(source, CompressionMode.Decompress))
|
||||||
|
{
|
||||||
|
int read = 0;
|
||||||
|
|
||||||
|
while (read < declared)
|
||||||
|
{
|
||||||
|
int n = inflate.Read(output, read, declared - read);
|
||||||
|
|
||||||
|
if (n <= 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
read += n;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (read != declared)
|
||||||
|
{
|
||||||
|
reason = "inflated " + read + " bytes, not the declared " + declared;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One more byte would mean the record is longer than its own header says,
|
||||||
|
// which is a different file from the one we were promised.
|
||||||
|
if (inflate.ReadByte() != -1)
|
||||||
|
{
|
||||||
|
reason = "inflated past the declared " + declared + " bytes";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = output;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
reason = "inflate failed: " + e.GetType().Name;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the payload ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>One decoded frame: ARGB1555 in our own array, no <c>Bitmap</c> anywhere.</summary>
|
||||||
|
public sealed class Pixels
|
||||||
|
{
|
||||||
|
public int Width;
|
||||||
|
public int Height;
|
||||||
|
public int CenterX;
|
||||||
|
public int CenterY;
|
||||||
|
public ushort[] Argb1555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One action of one body — every direction of it, concatenated.
|
||||||
|
///
|
||||||
|
/// The legacy files address a frame as <c>index + action * 5 + direction</c>; a UOP
|
||||||
|
/// group file holds the whole action in one record and the directions are equal-length
|
||||||
|
/// runs inside its frame table. So <see cref="DirectionAt"/> is where "direction 1" is
|
||||||
|
/// turned into a frame number, and it is integer division exactly as the reference
|
||||||
|
/// implementations do it — see the note there for the nine bodies where that matters.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Group
|
||||||
|
{
|
||||||
|
private readonly byte[] _buf;
|
||||||
|
private readonly int _dataStart;
|
||||||
|
|
||||||
|
public readonly int FrameCount;
|
||||||
|
public readonly int Body;
|
||||||
|
|
||||||
|
private Group(byte[] buf, int body, int frameCount, int dataStart)
|
||||||
|
{
|
||||||
|
_buf = buf;
|
||||||
|
Body = body;
|
||||||
|
FrameCount = frameCount;
|
||||||
|
_dataStart = dataStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryOpen(byte[] buf, int expectedBody, out Group group, out string reason)
|
||||||
|
{
|
||||||
|
group = null;
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
if (buf == null || buf.Length < 40)
|
||||||
|
{
|
||||||
|
reason = "payload is too short to carry a header";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BitConverter.ToInt32(buf, 0) != PayloadMagic)
|
||||||
|
{
|
||||||
|
reason = "payload is not an AMOU animation record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int body = BitConverter.ToInt32(buf, 12);
|
||||||
|
|
||||||
|
// The container said which body this is, by the name it was stored under; the
|
||||||
|
// payload says it again. They agree on every record of this client, and the day
|
||||||
|
// they do not is the day something is being read that was not asked for.
|
||||||
|
if (body != expectedBody)
|
||||||
|
{
|
||||||
|
reason = "payload declares body " + body + ", not " + expectedBody;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int frameCount = BitConverter.ToInt32(buf, 32);
|
||||||
|
int dataStart = BitConverter.ToInt32(buf, 36);
|
||||||
|
|
||||||
|
if (frameCount <= 0 || frameCount > BridgeAssetValidator.MaxAnimFrames)
|
||||||
|
{
|
||||||
|
reason = "payload declares " + frameCount + " frames";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataStart < 40 || dataStart > buf.Length)
|
||||||
|
{
|
||||||
|
reason = "frame table starts at " + dataStart + " of " + buf.Length;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((long)dataStart + ((long)frameCount * FrameRowBytes) > buf.Length)
|
||||||
|
{
|
||||||
|
reason = "frame table of " + frameCount + " rows runs past the record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
group = new Group(buf, body, frameCount, dataStart);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which frame of this action faces a given direction.
|
||||||
|
///
|
||||||
|
/// Five directions share the action's frames equally, so direction *d* starts at
|
||||||
|
/// <c>d * (FrameCount / 5)</c>. On nine of this client's 244 UOP bodies the frame
|
||||||
|
/// count is **not** a multiple of five (41, 42, 46…), and integer division then
|
||||||
|
/// lands a direction or so early in the run. That is what ClassicUO does, it is
|
||||||
|
/// the right trade, and the reason is §4.8's: the failure being guarded against is
|
||||||
|
/// a picture of the **wrong creature**, and this cannot produce one — the worst
|
||||||
|
/// case is the right creature at a slightly different angle, on nine bodies, where
|
||||||
|
/// refusing them instead would lose nine creatures outright.
|
||||||
|
/// </summary>
|
||||||
|
public int DirectionAt(int direction)
|
||||||
|
{
|
||||||
|
int perDirection = FrameCount / 5;
|
||||||
|
|
||||||
|
if (perDirection <= 0)
|
||||||
|
return direction == 0 ? 0 : -1;
|
||||||
|
|
||||||
|
if (direction < 0 || direction > 4)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
int at = direction * perDirection;
|
||||||
|
|
||||||
|
return at < FrameCount ? at : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decodes one frame, bounding every read against the record and every write
|
||||||
|
/// against the bitmap.
|
||||||
|
///
|
||||||
|
/// The run loop is <c>Ultima.Frame</c>'s, with the two bounds it does not have.
|
||||||
|
/// <c>Frame</c> writes through a <c>LockBits</c> pointer whose origin comes from
|
||||||
|
/// two signed shorts in the file and never checks where a run lands; here a run
|
||||||
|
/// that would leave the bitmap, or read past the record, refuses the frame. Across
|
||||||
|
/// every UOP body on a stock client that refuses nothing that carries art.
|
||||||
|
///
|
||||||
|
/// A 0×0 frame returns false with <paramref name="empty"/> set: the legacy decoder
|
||||||
|
/// treats that as no art rather than as damage, and so must this, or body 286
|
||||||
|
/// would be logged as a defect on every scan.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryDecode(int index, out Pixels pixels, out bool empty, out string reason)
|
||||||
|
{
|
||||||
|
pixels = null;
|
||||||
|
empty = false;
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
if (index < 0 || index >= FrameCount)
|
||||||
|
{
|
||||||
|
reason = "frame " + index + " of " + FrameCount;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int row = _dataStart + (index * FrameRowBytes);
|
||||||
|
|
||||||
|
long at = (long)row + (uint)BitConverter.ToInt32(_buf, row + 12);
|
||||||
|
|
||||||
|
if (at < 0 || at + PaletteBytes + 8 > _buf.Length)
|
||||||
|
{
|
||||||
|
reason = "frame " + index + " points outside the record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int pixelAt = (int)at;
|
||||||
|
|
||||||
|
int centerX = BitConverter.ToInt16(_buf, pixelAt + PaletteBytes);
|
||||||
|
int centerY = BitConverter.ToInt16(_buf, pixelAt + PaletteBytes + 2);
|
||||||
|
int width = BitConverter.ToUInt16(_buf, pixelAt + PaletteBytes + 4);
|
||||||
|
int height = BitConverter.ToUInt16(_buf, pixelAt + PaletteBytes + 6);
|
||||||
|
|
||||||
|
if (width <= 0 || height <= 0)
|
||||||
|
{
|
||||||
|
empty = true;
|
||||||
|
reason = "frame " + index + " is " + width + "x" + height;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width > BridgeAssetValidator.MaxArtDimension
|
||||||
|
|| height > BridgeAssetValidator.MaxArtDimension)
|
||||||
|
{
|
||||||
|
reason = "frame " + index + " declares " + width + "x" + height;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var palette = new ushort[0x100];
|
||||||
|
|
||||||
|
for (int i = 0; i < palette.Length; i++)
|
||||||
|
{
|
||||||
|
// The library's own xor: the stored entry has its alpha bit clear and every
|
||||||
|
// palette colour is opaque. A pixel no run covers stays zero, which is how a
|
||||||
|
// sprite keeps its transparent background.
|
||||||
|
palette[i] = (ushort)(BitConverter.ToUInt16(_buf, pixelAt + (i * 2)) ^ 0x8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
var canvas = new ushort[width * height];
|
||||||
|
|
||||||
|
int p = pixelAt + PaletteBytes + 8;
|
||||||
|
|
||||||
|
int xBase = centerX - 0x200;
|
||||||
|
int yBase = (centerY + height) - 0x200;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (p + 4 > _buf.Length)
|
||||||
|
{
|
||||||
|
reason = "frame " + index + " has no terminator inside the record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int header = BitConverter.ToInt32(_buf, p);
|
||||||
|
p += 4;
|
||||||
|
|
||||||
|
if (header == 0x7FFF7FFF)
|
||||||
|
break;
|
||||||
|
|
||||||
|
header ^= DoubleXor;
|
||||||
|
|
||||||
|
int x = ((header >> 22) & 0x3FF) + xBase;
|
||||||
|
int y = ((header >> 12) & 0x3FF) + yBase;
|
||||||
|
int run = header & 0xFFF;
|
||||||
|
|
||||||
|
if (run == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (p + run > _buf.Length)
|
||||||
|
{
|
||||||
|
reason = "frame " + index + " has a run past the end of the record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (y < 0 || y >= height || x < 0 || x + run > width)
|
||||||
|
{
|
||||||
|
reason = "frame " + index + " has a run at " + x + "," + y + " of "
|
||||||
|
+ run + " outside " + width + "x" + height;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cursor = (y * width) + x;
|
||||||
|
|
||||||
|
for (int i = 0; i < run; i++)
|
||||||
|
canvas[cursor + i] = palette[_buf[p + i]];
|
||||||
|
|
||||||
|
p += run;
|
||||||
|
}
|
||||||
|
|
||||||
|
pixels = new Pixels
|
||||||
|
{
|
||||||
|
Width = width,
|
||||||
|
Height = height,
|
||||||
|
CenterX = centerX,
|
||||||
|
CenterY = centerY,
|
||||||
|
Argb1555 = canvas
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user