diff --git a/docs/architecture/win-phone-field-map.md b/docs/architecture/win-phone-field-map.md new file mode 100644 index 000000000..ea7779ce6 --- /dev/null +++ b/docs/architecture/win-phone-field-map.md @@ -0,0 +1,93 @@ +# Windows Phone editor field map + +This map records how the typed pre-tile primitive sequence is connected to +TEdit's `World` model. Primitive indices are logical entries, not byte offsets. +All unknown entries retain their original kind and value when saving. + +Status values: + +- **Editable**: loaded into an existing `World` property and written back from it. +- **Derived**: written from structural world data but not independently editable. +- **Opaque**: preserved exactly; no editor property is assigned yet. + +## Container fields + +| Field | v49 | v60 | Status | +|---|---|---|---| +| Version | `uint32` | `uint32` | Structural; changing version is rejected | +| CRC | zero in tutorial | reflected CRC-32 | Recalculated for nonzero-CRC containers | +| Title | UTF-8 | UTF-16LE | Editable through `World.Title` | +| Cloud flag/history | absent | native header values | Opaque | + +## Common metadata primitives + +| Index | Kind | Meaning | TEdit property | Status | +|---:|---|---|---|---| +| 0 | `int32` | World ID | `WorldId` | Editable | +| 1 | `int32` | Native revision/legacy value | — | Opaque | +| 2 | `int32` | Right pixel bound | `RightWorld` | Derived | +| 3 | `int16` | Bottom pixel bound | `BottomWorld` | Derived | +| 4 | `int16` | Tile height | `TilesHigh` | Derived; resizing not supported | +| 5 | `int16` | Tile width | `TilesWide` | Derived; resizing not supported | +| 6 | `int16` | Dungeon X | `DungeonX` | Editable model field | +| 7 | `int16` | Dungeon Y | `DungeonY` | Editable model field | +| 8 | `int16` | Surface level | `GroundLevel` | Editable | +| 9 | `int16` | Rock level | `RockLevel` | Editable | +| 10 | `int32` bits | Time as `float32` | `Time` | Editable | +| 11 | `byte` | Daytime flag | `DayTime` | Editable through time control | +| 12 | `byte` | Moon phase | `MoonPhase` | Editable | +| 13 | `byte` | Blood moon flag | `BloodMoon` | Editable | +| 14 | `int16` | Compact time/moon helper state | — | Opaque | + +## v49 tail + +| Indices | Meaning | Status | +|---|---|---| +| 15–16 | Spawn X/Y (`int16`) | Editable through spawn tool | +| 17–19 | Eye of Cthulhu, Eater of Worlds, Skeletron defeated | Editable | +| 20–22 | Goblin, Wizard, Mechanic rescued | Editable | +| 23–25 | Goblin Army, Clown, Frost Legion defeated | Editable | +| 26 | Shadow orb smashed | Editable | +| 27 | Meteor queued | Editable | +| 28 | Shadow orb count (`byte`) | Editable | +| 29 | Altar count (`int32`) | Editable | +| 30 | Hardmode | Editable | +| 31 | Invasion delay (`byte`) | Editable model field; not shown separately in the editor | +| 32 | Invasion size (`int16`) | Editable | +| 33 | Invasion type (`byte`) | Editable | +| 34 | Invasion X as `float32` bits | Editable | + +## v60 tail + +| Indices | Meaning | Status | +|---|---|---| +| 15–16 | Additional compact helper bytes | Opaque | +| 17–18 | Spawn X/Y (`int16`) | Editable through spawn tool | +| 19 | Crimson-world flag | Editable through `IsCrimson` | +| 20 | Raining flag | Editable through `IsRaining` | +| 21 | Rain time | Editable through `TempRainTime` | +| 22 | Maximum rain intensity as `float32` bits | Editable through `TempMaxRain` | +| 23 | Native weather scheduling value | Opaque | +| 24–32 | Same nine common progression flags as v49 indices 17–25 | Editable | +| 33–34 | Lepus and Turkor defeated | Opaque; mobile-only with no TEdit model field | +| 35 | Queen Bee defeated | Editable | +| 36–38 | Destroyer, Twins, Skeletron Prime defeated | Editable | +| 39 | Any mechanical boss defeated | Derived from the three mechanical boss fields | +| 40–42 | Plantera, Golem, Pirate Invasion defeated | Editable | +| 43–46 | Shadow orb smashed, meteor queued, orb count, altar count | Editable | +| 47–49 | Cobalt, Mythril, Adamantite ore tier IDs (`int16`) | Editable | +| 50 | Hardmode | Editable | +| 51 | Additional time-state snapshot value (`int16`) | Opaque | +| 52–55 | Invasion delay, size, type, and X (`byte`, `int16`, `byte`, `float32`) | Editable | +| 56–69 | Two compact biome-state blocks | Opaque | +| 70–78 | Packed styles and eight background bytes | Opaque | + +## Editable sections + +Tiles, chests, signs, NPCs, and character names use the existing editor models. +Chests and signs are reconciled into their fixed 1,000 native slots at save time. +For tiles, unchanged columns preserve native records; edited columns are written +as validated one-tile records to avoid stale RLE boundaries. + +The corresponding raw Ghidra functions and direct-call indexes are stored under +`docs/reverse-engineering/winphone/ghidra/`. diff --git a/docs/architecture/win-phone.md b/docs/architecture/win-phone.md new file mode 100644 index 000000000..04f78eae2 --- /dev/null +++ b/docs/architecture/win-phone.md @@ -0,0 +1,219 @@ +# Windows Phone world format + +Status: Windows Phone v49 and v60 containers are detected and decoded through their character-name tables. The native header, metadata primitives, tiles, chests, signs, NPCs, and names are reconstructed by a section-aware serializer. All three fixtures save byte-for-byte through `World.Save`. + +The implementation is in `src/TEdit.Terraria/World.FileWinPhone.cs`. Reverse-engineering notes for the local Ghidra setup are in `ghidra-winphone-quick-ref.md`. +The editor-to-native metadata mapping is tracked in `win-phone-field-map.md`, and +raw saved Ghidra decompilations are indexed under +`docs/reverse-engineering/winphone/ghidra/`. + +## Verified fixtures + +| Fixture | Version | Size | Result | +|---|---:|---:|---| +| `_reference_sources/winphone/Terraria v1.0.0.0/Tutorial.world` | 49 | 1,538,009 | Full tile load and byte-identical save | +| `_reference_sources/winphone/Terraria v1.2.4.3/Tutorial.world` | 49 | 1,538,009 | Full tile load and byte-identical save; identical to the v1.0 copy | +| `src/TEdit.Tests/WorldFiles/win-phone.world` | 60 | 4,210,977 | Full tile load and byte-identical save | + +The two tutorial files have SHA-256 `2E06CDBDBB29604831AA149A94CDBA8A5C06A20C9DF6D0CE46F0BFEDBC2061F9`. + +## Native container header + +All integers are little-endian. + +| Field | Encoding | +|---|---| +| Version | `uint32` | +| CRC-32 | `uint32`; reflected polynomial `0xEDB88320`, calculated over bytes from offset 8 through EOF | +| Title length | `int32` character/byte count | +| Title | UTF-8 for versions below 50; UTF-16LE for version 50 and newer | +| Cloud flag | One byte for version 53 and newer | +| History | Version 53 has one `uint32`; later versions have `int32 count` followed by that many `uint32` values | + +The v49 tutorial CRC field is zero. The v60 fixture contains and passes the native CRC calculation. The value previously described as a fixed magic number is therefore a file-specific CRC, not a format signature. + +The body begins with these verified fields: + +| Relative offset | Field | Encoding | +|---:|---|---| +| `+0` | World ID | `int32` | +| `+4` | Revision/legacy value | `int32` where present | +| `+8` | Right pixel bound | `int32` | +| `+12` | Bottom pixel bound | `int16` | +| `+14` | Tile height | `int16` | +| `+16` | Tile width | `int16` | +| `+18` | Dungeon X | `int16` | +| `+20` | Dungeon Y | `int16` | +| `+22` | Surface level | `int16` | +| `+24` | Rock level | `int16` | + +Detection validates `right == width * 16` and `bottom == height * 16`. This prevents ordinary desktop V1 files in the same version range from being claimed as Windows Phone files. + +### World dimensions + +The v1.2.4.3 application shipped two creation sizes: + +| Mobile label | Dimensions | Evidence | +|---|---:|---| +| Normal | 1,750 × 1,000 tiles | `MAIN DESCRIPTION_Expanded` in the shipped localization data | +| Expanded | 4,200 × 1,200 tiles | Shipped localization and native creation routine `FUN_007e5898` | + +The localization calls the expanded size equivalent to a small PC world and +warns that it may perform poorly below 1 GB of memory. The native tile loader +and writer iterate over the dimensions loaded from the file, but no shipped +source or fixture establishes support for PC medium (6,400 × 1,800) worlds. + +PC large (8,400 × 2,400) worlds cannot be represented correctly. Although the +tile width fits its signed `int16` field, the bottom pixel bound is also an +`int16`; `2,400 * 16` is 38,400 and overflows it. The structural height ceiling +is therefore 2,047 tiles. Save As conversion should use the proven native +4,200 × 1,200 expanded size as its conservative maximum and reject larger +worlds instead of truncating dimensions. + +The remaining pre-tile metadata has an exact versioned primitive layout: + +| Block | v49 | v60 | +|---|---:|---:| +| Common bounds and layers above | 26 bytes | 26 bytes | +| World-state helper (`FUN_0073527c`/`FUN_00735318`) | 9 bytes | 11 bytes | +| Spawn X/Y | 4 bytes | 4 bytes | +| Extra v58 state plus weather (`FUN_00749890`/`FUN_00749d20`) | — | 14 bytes | +| Progression flags (`FUN_006598b4`/`FUN_00659a38`) | 9 bytes | 19 bytes | +| Moon/time/invasion/ore fields | 16 bytes | 24 bytes | +| Two biome-state blocks, packed styles, eight background bytes | — | 29 bytes | +| Total body prefix before the first marker | 64 bytes | 127 bytes | + +Fields without a confirmed gameplay name are retained as typed `byte`, `int16`, or `int32` primitives rather than assigned speculative names. The serializer writes the primitive sequence, not copied source bytes. + +Confirmed editor-backed fields are now materialized from the prefix and written +back from the normal `World` model: world ID, dungeon and spawn coordinates, +surface and rock levels, time and moon state, progression flags, rescued NPCs, +orb/meteor/altar state, hardmode, ore tiers, and the active invasion state. +The compact time value is a `float32` stored in the primitive sequence as its raw +four-byte bit pattern. Spawn follows the compact time/moon helper (9 bytes below +v58 and 11 bytes at v58+); it is not the pair at body offsets `+18/+20`. + +The native body is divided by the little-endian `int32` marker `0x162E`. Its complete sequence is: + +1. Metadata, marker. +2. Column-major tile grid, marker. +3. 1,000 sparse chest slots, marker. +4. 1,000 sparse sign slots, marker. +5. Sentinel-terminated NPC records, marker. +6. Character-name strings: 10 for v49 and 18 for v60. + +## Two tile encodings + +The v1.0 executable does not use one version-conditional tile reader. Its body loader dispatches versions below 58 to a legacy reader and versions 58 or newer to a compact reader. + +### Versions below 58 + +Recovered from `FUN_00733bac` in the v1.0.0.0 executable. Each record describes one tile followed by zero or more vertical duplicates. + +| Condition | Field | Encoding | +|---|---|---| +| Always | Active | `byte`, zero or nonzero | +| Active | Stored tile type | `byte` | +| Active and legacy-framed | Frame U, frame V | Two `int16` values | +| Always | Wall type | `byte` | +| Version > 50 | Wall color | `byte` | +| Always | Liquid amount | `byte` | +| Liquid amount != 0 | Lava flag | `byte`, zero for water and nonzero for lava | +| Always | Tile flags | `byte` | +| Always | Vertical repeat count | `int16` | + +The repeat count excludes the first tile, so a value of zero represents one tile. Runs cannot cross a column boundary. + +The legacy frame predicate is recovered exactly from `FUN_0072ad80`. It includes IDs 3–5; 10–18 except 19; 20, 21, 24, 26–29, 31, 33–36, 42, 50, 55, 61, 71–74, 77–79, 81–106, 110, 113, 114, 125–129, 132–139, 141–144, 149, and 150. + +The native migration maps stored types 35 and 36 to type 34 while shifting their frame V by 54 and 108 respectively. Stored type 150 maps to the internal placeholder type 500. Type 127 is decoded and then deactivated. + +### Versions 58 and newer + +Recovered from `FUN_00733858`. The v60 fixture uses this encoding. + +| Condition | Field | Encoding | +|---|---|---| +| Always | Header 1 | `byte`: active, red/green/blue wire, actuator, inactive flags | +| Active | Tile type | `uint16`; the native tile field is nine bits wide | +| Active and frame-important | Frame U, frame V | Two `int16` values | +| Active | Tile color | `byte` | +| Always | Wall type | `byte` | +| Wall != 0 | Wall color | `byte` | +| Always | Liquid amount | `byte` | +| Liquid amount != 0 | Liquid kind | `byte`; native value plus one maps to TEdit's enum | +| Always | Header 2 | `byte`; includes yellow wire | +| Versions 60–68 | Shape | `byte`; high nibble is the brick style | +| Always | Vertical repeat count | One or two bytes, 7-bit continuation encoding | + +Type 500 is a valid framed internal placeholder in the v60 stream, not corrupt input. + +The original RLE boundaries are encoding metadata. The v49 tutorial sometimes ends a run before a following semantically identical tile, so maximizing every run changes six bytes even though the decoded grid is unchanged. TEdit retains and validates those native boundaries. + +## Chests, signs, NPCs, and names + +Each chest slot begins with a presence byte and, when present, `int16 X`, `int16 Y`. + +- v49 has 20 items per chest. Every slot stores a one-byte stack; nonempty items then store `int16 net ID` and `byte prefix`. +- v60 has 40 items per chest. A one-byte mask length and five mask bytes select populated slots; each selected item stores `int16 stack`, `int16 net ID`, and `byte prefix`. + +Each sign slot begins with a presence byte and, when present, `int16 X`, `int16 Y`. v60 then has a text-present byte. Text uses the container string encoding: `int32 length` plus UTF-8 below version 50 or UTF-16LE at version 50 and newer. + +NPC records begin with byte `1`, then contain `byte sprite ID`, two `float32` positions, a homeless byte, and two `int16` home coordinates. A zero presence byte terminates the list. + +The character-name IDs are `17, 18, 19, 20, 22, 54, 38, 107, 108, 124, 160, 178, 207, 208, 209, 227, 228, 229`. v49 stores the first 10; v60 stores all 18. + +The v49 `Tutorial.world` is a fat bundled resource. The native v49 reader stops at offset 201,842 after its tenth name and ignores the remaining 1,336,167 bytes. TEdit reconstructs the complete parsed world and preserves this ignored bundle suffix verbatim. The v60 file ends at its eighteenth name. + +## Save behavior + +`World.SaveWinPhoneUnchanged` remains an explicit raw-copy utility. The normal +`World.Save` and `World.SaveAsync` paths require a full load, validate supported +edits, and reconstruct the header, metadata, tiles, section markers, chests, +signs, NPCs, and character names. They do not emit the original parsed bytes. + +Header edits for the confirmed editor-backed fields are serialized, and nonzero +native CRC values are regenerated. Chests and signs are reconciled back into the +native 1,000-slot sparse tables; NPCs and character names serialize from their +normal TEdit collections. A tile edit causes that native column to be rewritten +as one-tile records while unchanged columns retain their original flags and RLE +boundaries. + +Save As offers a Windows Phone v1.2.4.3 target that writes native version 60. +Compatibility is applied only while writing that target: titles are shortened +to 128 characters, excess sparse entries and unsupported NPC IDs are omitted, +item stacks are clamped, and tile, wall, and liquid values unavailable to the +native format are written as their empty equivalents. Numeric metadata is +truncated and clamped to its native primitive width. World dimensions are the +exception: worlds larger than the proven 4,200 × 1,200 expanded size are +rejected rather than cropped or allowed to overflow structural fields. + +## Recovered native functions + +Addresses below are from the v1.0.0.0 Ghidra project. + +| Address | Purpose | +|---|---| +| `00768ed0` | Read the container version/header | +| `0076a604` | Dispatch body loader at version 58 | +| `00767f5c` | Legacy body reader | +| `00768798` | New body reader | +| `00733bac` | Legacy tile reader used by v49 | +| `00733858` | Compact tile reader used by v58+ | +| `0072ad80` | Legacy framed-tile predicate | +| `00733e90` | Current compact tile writer | +| `0076758c` | World body writer and section markers | +| `00605a60` / `00605b9c` | Read/write one chest inventory | +| `006082a4` / `00606768` | Read/write 1,000 chest slots | +| `00726440` / `007260ec` | Read/write one sign slot | +| `00735318` / `0073527c` | Read/write the compact world-state helper | +| `00749d20` / `00749890` | Read/write weather state | +| `00659a38` / `006598b4` | Read/write progression flags | + +## Tests + +`WinPhoneWorldTests` verifies header dispatch, complete tile allocation, decoded +section counts, native validation, byte-for-byte no-op saves, and edit/reload +behavior for metadata, progression, world state, tiles, and sparse entity data. + +The strongest serializer test loads each file, overwrites every cached source byte from offset zero through the end of the parsed world with `0xA5`, and reconstructs it. Output still matches the original byte-for-byte. For v60 this means no original file bytes participate in reconstruction; for v49 only the native-reader-ignored bundle suffix is preserved. diff --git a/docs/todo/README.md b/docs/todo/README.md index 09d1f96de..c3e81de0c 100644 --- a/docs/todo/README.md +++ b/docs/todo/README.md @@ -1,9 +1,5 @@ # TEdit Release Todo -## Source-Derived Triage - -- [video-transcript-fix-list.md](video-transcript-fix-list.md) — field-reported data-loss, transform, morph, masking, and plugin issues from a custom-world tutorial - ## Masking System Overhaul (#2168) - [masking-system.md](masking-system.md) — Liquid masking, decoupled masks, wire mode extension - [pattern-selection.md](pattern-selection.md) — Unified tile picker control (compact 24x24 + full mode), pattern fill system diff --git a/src/TEdit.Terraria/TEdit.Terraria.csproj b/src/TEdit.Terraria/TEdit.Terraria.csproj index 99d7cb11d..6c51c0cbc 100644 --- a/src/TEdit.Terraria/TEdit.Terraria.csproj +++ b/src/TEdit.Terraria/TEdit.Terraria.csproj @@ -7,9 +7,10 @@ - - - + + + + diff --git a/src/TEdit.Terraria/World.FileWinPhone.cs b/src/TEdit.Terraria/World.FileWinPhone.cs new file mode 100644 index 000000000..dbfbeb2ad --- /dev/null +++ b/src/TEdit.Terraria/World.FileWinPhone.cs @@ -0,0 +1,1806 @@ +using System; +using System.ComponentModel; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using TEdit.Terraria.Objects; +using TEdit.Utility; + +namespace TEdit.Terraria; + +public partial class World +{ + private const int WinPhoneMaxTitleLength = 128; + internal const int WinPhoneMaxWorldWidth = 4200; + internal const int WinPhoneMaxWorldHeight = 1200; + private static readonly ushort[] WinPhoneFrameImportantIds = + [ + 3, 4, 5, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 24, 26, 27, 28, 29, + 31, 33, 34, 35, 36, 42, 50, 55, 61, 71, 72, 73, 74, 77, 78, 79, 81, 82, 83, 84, + 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, + 103, 104, 105, 106, 110, 113, 114, 125, 126, 128, 129, 132, 133, 134, 135, + 136, 137, 138, 139, 141, 142, 143, 144, 149, 165, 171, 172, 173, 174, 178, 184, + 185, 186, 187, 201, 207, 209, 210, 212, 215, 216, 217, 218, 219, 220, 227, + 228, 231, 233, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, + 247, 254, 269, 270, 271, 275, 276, 277, 278, 279, 280, 281, 282, 283, 285, + 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, + 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 314, 316, 317, 318, 319, + 320, 323, 324, 334, 335, 337, 338, 339, 500, + ]; + + /// + /// Detects and reads the native Windows Phone world container header. + /// Versions through 65 have an uncompressed body; version 66 and newer use + /// the native game's body transform and are not accepted by this reader yet. + /// + internal static bool TryLoadWinPhone( + BinaryReader reader, + World world, + bool headersOnly, + IProgress? progress = null) + { + Stream stream = reader.BaseStream; + long originalPosition = stream.Position; + + try + { + stream.Position = 0; + byte[] source = reader.ReadBytes(checked((int)stream.Length)); + if (!TryReadWinPhoneHeader(source, out WinPhoneHeader header)) + { + stream.Position = originalPosition; + return false; + } + + world.IsWinPhone = true; + world.WinPhoneSourceData = source; + world.WinPhoneStoredCrc = header.Crc; + world.WinPhoneCloudFlag = header.CloudFlag; + world.WinPhoneHistory = header.History; + world.Version = header.Version; + world.Title = header.Title; + world.WorldId = header.WorldId; + world.Rand = new Random(world.WorldId); + world.LeftWorld = 0; + world.RightWorld = header.RightWorld; + world.TopWorld = 0; + world.BottomWorld = header.BottomWorld; + world.TilesHigh = header.TilesHigh; + world.TilesWide = header.TilesWide; + world.SpawnX = header.SpawnX; + world.SpawnY = header.SpawnY; + world.DungeonX = header.DungeonX; + world.DungeonY = header.DungeonY; + world.GroundLevel = header.GroundLevel; + world.RockLevel = header.RockLevel; + + progress?.Report(new ProgressChangedEventArgs(0, "Loaded Windows Phone world header.")); + + if (!headersOnly) + { + int position = LoadWinPhoneTiles(source, header, world, progress); + LoadWinPhoneSections(source, header, world, position); + } + + return true; + } + catch + { + stream.Position = originalPosition; + throw; + } + } + + private static int LoadWinPhoneTiles( + ReadOnlySpan source, + WinPhoneHeader header, + World world, + IProgress? progress) + { + int markerOffset = FindSectionMarker(source, header.BodyOffset); + if (markerOffset < 0) + throw new InvalidDataException("Windows Phone tile section marker was not found."); + + int position = markerOffset + 4; + world.WinPhoneMetadataPrimitives = ReadWinPhoneMetadataPrimitives( + source, header.BodyOffset, markerOffset, header.Version); + ApplyWinPhoneMetadataToWorld(world, header.Version); + world.WinPhoneTileSectionOffset = position; + bool[] frameImportant = CreateWinPhoneFrameImportant(header.Version); + world.TileFrameImportant = (bool[])frameImportant.Clone(); + world.Tiles = new Tile[world.TilesWide, world.TilesHigh]; + int tileCount = checked(world.TilesWide * world.TilesHigh); + world.WinPhoneStoredTileTypes = new ushort[tileCount]; + world.WinPhoneTileHeader1 = new byte[tileCount]; + world.WinPhoneTileHeader2 = new byte[tileCount]; + world.WinPhoneTileShape = new byte[tileCount]; + world.WinPhoneLegacyTileFlags = new byte[tileCount]; + world.WinPhoneTileRunLengths = new ushort[tileCount]; + + // The native loader has two independent tile decoders. Versions below + // 58 use FUN_00733bac; versions 58 and newer use FUN_00733858. + if (header.Version < 58) + { + LoadWinPhoneLegacyTiles(source, header, world, frameImportant, ref position, progress); + ValidateWinPhoneTileEndMarker(source, position); + return position + 4; + } + + int encodedHeight = world.TilesHigh; + + for (int x = 0; x < world.TilesWide; x++) + { + Queue trace = new(); + if ((x & 31) == 0) + progress?.Report(new ProgressChangedEventArgs( + x.ProgressPercentage(world.TilesWide), + "Loading Windows Phone tiles...")); + + for (int y = 0; y < encodedHeight;) + { + byte header1 = ReadByte(source, ref position); + int auxiliaryIndex = GetWinPhoneTileIndex(world, x, y); + world.WinPhoneTileHeader1[auxiliaryIndex] = header1; + Tile tile = new() + { + IsActive = (header1 & 0x01) != 0, + WireRed = (header1 & 0x02) != 0, + WireGreen = (header1 & 0x04) != 0, + WireBlue = (header1 & 0x08) != 0, + Actuator = (header1 & 0x10) != 0, + InActive = (header1 & 0x20) != 0, + }; + + if (tile.IsActive) + { + int tileOffset = position; + tile.Type = ReadUInt16(source, ref position); + world.WinPhoneStoredTileTypes[auxiliaryIndex] = tile.Type; + + trace.Enqueue($"y={y}:type={tile.Type}@{tileOffset}"); + while (trace.Count > 20) + trace.Dequeue(); + + // The native tile field is nine bits wide. Windows Phone + // uses framed type 500 as an internal placeholder tile. + if (tile.Type > 0x1FF) + throw new InvalidDataException( + $"Invalid Windows Phone tile type {tile.Type} at x={x}, y={y}, offset={position}. Trace: {string.Join("; ", trace)}"); + + bool isFramed = tile.Type < frameImportant.Length + ? frameImportant[tile.Type] + : true; + if (isFramed) + { + tile.U = ReadInt16(source, ref position); + tile.V = ReadInt16(source, ref position); + } + else + { + tile.U = -1; + tile.V = -1; + } + + tile.TileColor = ReadByte(source, ref position); + } + + tile.Wall = ReadByte(source, ref position); + if (tile.Wall != 0) + tile.WallColor = ReadByte(source, ref position); + + tile.LiquidAmount = ReadByte(source, ref position); + if (tile.LiquidAmount != 0) + { + byte nativeLiquidType = ReadByte(source, ref position); + tile.LiquidType = (LiquidType)(nativeLiquidType + 1); + } + + byte header2 = ReadByte(source, ref position); + world.WinPhoneTileHeader2[auxiliaryIndex] = header2; + tile.WireYellow = (header2 & 0x10) != 0; + + if (header.Version is >= 60 and <= 68) + { + byte shape = ReadByte(source, ref position); + world.WinPhoneTileShape[auxiliaryIndex] = shape; + tile.BrickStyle = (BrickStyle)(shape >> 4); + } + + int rle = ReadByte(source, ref position); + if ((rle & 0x80) != 0) + rle = (rle & 0x7F) | ReadByte(source, ref position) << 7; + world.WinPhoneTileRunLengths[auxiliaryIndex] = (ushort)rle; + + if (y + rle >= encodedHeight) + throw new InvalidDataException( + $"Windows Phone tile run exceeds the world height at x={x}, y={y}, rle={rle}, offset={position}. Trace: {string.Join("; ", trace)}"); + + tile.ResetCache(); + if (y < world.TilesHigh) + world.Tiles[x, y] = tile; + for (int repeat = 1; repeat <= rle && y + repeat < world.TilesHigh; repeat++) + { + world.Tiles[x, y + repeat] = tile; + CopyWinPhoneTileAuxiliary(world, x, y, y + repeat); + } + y += rle + 1; + } + } + + ValidateWinPhoneTileEndMarker(source, position); + return position + 4; + } + + private static void LoadWinPhoneSections( + ReadOnlySpan source, + WinPhoneHeader header, + World world, + int position) + { + world.WinPhoneDataSectionOffset = position; + LoadWinPhoneChests(source, header.Version, world, ref position); + ReadWinPhoneSectionMarker(source, ref position, "chests"); + LoadWinPhoneSigns(source, header.Version, world, ref position); + ReadWinPhoneSectionMarker(source, ref position, "signs"); + LoadWinPhoneNpcs(source, world, ref position); + ReadWinPhoneSectionMarker(source, ref position, "NPCs"); + LoadWinPhoneCharacterNames(source, header.Version, world, ref position); + world.WinPhoneParsedLength = position; + + // The bundled v49 Tutorial.world is a fat resource: the native v49 + // reader stops after ten legacy NPC names at offset 202,363 and ignores + // the remaining bundled data. Later native saves end at the name table. + if (header.Version >= 50 && position != source.Length) + throw new InvalidDataException( + $"Windows Phone world has {source.Length - position} unexpected trailing bytes."); + } + + private static void LoadWinPhoneChests( + ReadOnlySpan source, + uint version, + World world, + ref int position) + { + world.Chests.Clear(); + world.WinPhoneChestSlots = new Chest?[Chest.LegacyLimit]; + int itemCount = version < 58 ? 20 : 40; + byte[] itemMask = new byte[5]; + + for (int chestIndex = 0; chestIndex < Chest.LegacyLimit; chestIndex++) + { + if (ReadByte(source, ref position) == 0) + continue; + + Chest chest = new(ReadInt16(source, ref position), ReadInt16(source, ref position)) + { + MaxItems = itemCount, + }; + + Array.Clear(itemMask); + if (version < 58) + { + itemMask.AsSpan(0, 3).Fill(byte.MaxValue); + } + else + { + int maskLength = ReadByte(source, ref position); + if (maskLength is < 0 or > 5) + throw new InvalidDataException($"Invalid Windows Phone chest item mask length {maskLength}."); + for (int i = 0; i < maskLength; i++) + itemMask[i] = ReadByte(source, ref position); + } + + for (int slot = 0; slot < itemCount; slot++) + { + if ((itemMask[slot >> 3] & 1 << (slot & 7)) == 0) + continue; + + int stack = version < 55 + ? ReadByte(source, ref position) + : ReadInt16(source, ref position); + if (stack <= 0) + continue; + + int netId = ReadInt16(source, ref position); + byte prefix = ReadByte(source, ref position); + chest.Items[slot] = new Item(stack, netId, prefix); + } + + world.Chests.Add(chest); + world.WinPhoneChestSlots[chestIndex] = chest; + } + } + + private static void LoadWinPhoneSigns( + ReadOnlySpan source, + uint version, + World world, + ref int position) + { + world.Signs.Clear(); + world.WinPhoneSignSlots = new Sign?[Sign.LegacyLimit]; + world.WinPhoneSignTextFlags = new byte[Sign.LegacyLimit]; + for (int signIndex = 0; signIndex < Sign.LegacyLimit; signIndex++) + { + if (ReadByte(source, ref position) == 0) + continue; + + short x = ReadInt16(source, ref position); + short y = ReadInt16(source, ref position); + if (version > 57) + world.WinPhoneSignTextFlags[signIndex] = ReadByte(source, ref position); + string text = ReadWinPhoneString(source, version, ref position); + Sign sign = new(x, y, text); + world.Signs.Add(sign); + world.WinPhoneSignSlots[signIndex] = sign; + } + } + + private static void LoadWinPhoneNpcs( + ReadOnlySpan source, + World world, + ref int position) + { + world.NPCs.Clear(); + while (ReadByte(source, ref position) != 0) + { + int spriteId = ReadByte(source, ref position); + NPC npc = new() + { + SpriteId = spriteId, + Position = new Vector2FloatObservable( + ReadSingle(source, ref position), + ReadSingle(source, ref position)), + IsHomeless = ReadByte(source, ref position) != 0, + Home = new Vector2Int32Observable( + ReadInt16(source, ref position), + ReadInt16(source, ref position)), + }; + npc.Name = WorldConfiguration.NpcNames.TryGetValue(spriteId, out string? name) + ? name + : $"NPC {spriteId}"; + world.NPCs.Add(npc); + } + } + + private static readonly int[] WinPhoneCharacterNameIds = + [ + 17, 18, 19, 20, 22, 54, 38, 107, 108, 124, 160, 178, 207, 208, 209, 227, 228, 229, + ]; + + private static void LoadWinPhoneCharacterNames( + ReadOnlySpan source, + uint version, + World world, + ref int position) + { + world.CharacterNames.Clear(); + int nameCount = version < 50 ? 10 : WinPhoneCharacterNameIds.Length; + for (int index = 0; index < nameCount; index++) + world.CharacterNames.Add(new NpcName( + WinPhoneCharacterNameIds[index], + ReadWinPhoneString(source, version, ref position))); + } + + private static string ReadWinPhoneString( + ReadOnlySpan source, + uint version, + ref int position) + { + int length = ReadInt32(source, position); + int lengthOffset = position; + position += 4; + if (length < 0 || length > 1_000_000) + throw new InvalidDataException( + $"Invalid Windows Phone string length {length} at offset {lengthOffset}."); + + int byteLength = checked(length * (version < 50 ? 1 : 2)); + if (position + byteLength > source.Length) + throw new EndOfStreamException(); + string value = version < 50 + ? Encoding.UTF8.GetString(source.Slice(position, byteLength)) + : Encoding.Unicode.GetString(source.Slice(position, byteLength)); + position += byteLength; + return value; + } + + private static void ReadWinPhoneSectionMarker( + ReadOnlySpan source, + ref int position, + string precedingSection) + { + if (position + 4 > source.Length || ReadInt32(source, position) != 0x162E) + throw new InvalidDataException( + $"Windows Phone {precedingSection} section did not end at the expected marker."); + position += 4; + } + + private static void LoadWinPhoneLegacyTiles( + ReadOnlySpan source, + WinPhoneHeader header, + World world, + bool[] frameImportant, + ref int position, + IProgress? progress) + { + for (int x = 0; x < world.TilesWide; x++) + { + if ((x & 31) == 0) + progress?.Report(new ProgressChangedEventArgs( + x.ProgressPercentage(world.TilesWide), + "Loading legacy Windows Phone tiles...")); + + for (int y = 0; y < world.TilesHigh;) + { + int auxiliaryIndex = GetWinPhoneTileIndex(world, x, y); + bool active = ReadByte(source, ref position) != 0; + world.WinPhoneTileHeader1![auxiliaryIndex] = active ? (byte)1 : (byte)0; + Tile tile = new() { IsActive = active }; + + if (active) + { + byte storedType = ReadByte(source, ref position); + world.WinPhoneStoredTileTypes![auxiliaryIndex] = storedType; + ushort type = storedType switch + { + 35 or 36 when header.Version < 58 => 34, + 150 when header.Version < 58 => 500, + _ => storedType, + }; + tile.Type = type; + + bool isFramed = IsWinPhoneLegacyFramed(storedType); + if (isFramed) + { + tile.U = ReadInt16(source, ref position); + tile.V = ReadInt16(source, ref position); + + // The native migration folds old banner variants into + // type 34 while retaining their frame row. + if (header.Version < 58 && storedType == 35) + tile.V += 54; + else if (header.Version < 58 && storedType == 36) + tile.V += 108; + } + else + { + tile.U = -1; + tile.V = -1; + } + + if (tile.Type == 127) + tile.IsActive = false; + } + + tile.Wall = ReadByte(source, ref position); + if (header.Version > 50) + tile.WallColor = ReadByte(source, ref position); + + tile.LiquidAmount = ReadByte(source, ref position); + if (tile.LiquidAmount != 0) + tile.LiquidType = ReadByte(source, ref position) != 0 + ? LiquidType.Lava + : LiquidType.Water; + + byte flags = ReadByte(source, ref position); + world.WinPhoneLegacyTileFlags![auxiliaryIndex] = flags; + tile.WireRed = (flags & 0x10) != 0; + tile.WireGreen = (flags & 0x20) != 0; + tile.WireBlue = (flags & 0x40) != 0; + + int rle = ReadInt16(source, ref position); + if (rle < 0 || y + rle >= world.TilesHigh) + throw new InvalidDataException( + $"Legacy Windows Phone tile run exceeds the world height at x={x}, y={y}, rle={rle}, offset={position}."); + world.WinPhoneTileRunLengths![auxiliaryIndex] = (ushort)rle; + + tile.ResetCache(); + world.Tiles[x, y] = tile; + for (int repeat = 1; repeat <= rle; repeat++) + { + world.Tiles[x, y + repeat] = tile; + CopyWinPhoneTileAuxiliary(world, x, y, y + repeat); + } + y += rle + 1; + } + } + } + + private static int GetWinPhoneTileIndex(World world, int x, int y) => + checked(x * world.TilesHigh + y); + + private static void CopyWinPhoneTileAuxiliary(World world, int x, int sourceY, int targetY) + { + int sourceIndex = GetWinPhoneTileIndex(world, x, sourceY); + int targetIndex = GetWinPhoneTileIndex(world, x, targetY); + world.WinPhoneStoredTileTypes![targetIndex] = world.WinPhoneStoredTileTypes[sourceIndex]; + world.WinPhoneTileHeader1![targetIndex] = world.WinPhoneTileHeader1[sourceIndex]; + world.WinPhoneTileHeader2![targetIndex] = world.WinPhoneTileHeader2[sourceIndex]; + world.WinPhoneTileShape![targetIndex] = world.WinPhoneTileShape[sourceIndex]; + world.WinPhoneLegacyTileFlags![targetIndex] = world.WinPhoneLegacyTileFlags[sourceIndex]; + } + + private static void ValidateWinPhoneTileEndMarker(ReadOnlySpan source, int position) + { + // A second 0x162E marker follows the complete tile stream. + if (position + 4 > source.Length || ReadInt32(source, position) != 0x162E) + throw new InvalidDataException("Windows Phone tile section length did not match the world dimensions."); + } + + private static bool[] CreateWinPhoneFrameImportant(uint version) + { + bool[] result = new bool[Math.Max(WorldConfiguration.SettingsTileFrameImportant.Length, 512)]; + foreach (ushort id in WinPhoneFrameImportantIds) + result[id] = true; + if (version == 49) + { + // The pre-v58 decoder uses this predicate instead of the later + // property table (FUN_0072ad80). + Array.Clear(result); + for (int id = 0; id <= byte.MaxValue; id++) + result[id] = IsWinPhoneLegacyFramed((byte)id); + } + return result; + } + + private static bool IsWinPhoneLegacyFramed(byte type) => type switch + { + 3 or 4 or 5 or 10 or 11 or 12 or 13 or 14 or 15 or 16 or 17 or 18 or + 20 or 21 or 24 or 26 or 27 or 28 or 29 or 31 or 33 or 34 or 35 or 36 or + 42 or 50 or 55 or 61 or + >= 71 and <= 74 or >= 77 and <= 79 or >= 81 and <= 106 or + 110 or 113 or 114 or >= 125 and <= 129 or >= 132 and <= 139 or + >= 141 and <= 144 or 149 or 150 => true, + _ => false, + }; + + private static int FindSectionMarker(ReadOnlySpan source, int start) + { + int limit = Math.Min(source.Length - 4, start + 1024); + for (int position = start; position <= limit; position++) + { + if (ReadInt32(source, position) == 0x162E) + return position; + } + return -1; + } + + private static WinPhonePrimitive[] ReadWinPhoneMetadataPrimitives( + ReadOnlySpan source, + int start, + int markerOffset, + uint version) + { + List values = []; + byte[] metadataSource = source.Slice(start, markerOffset - start).ToArray(); + int position = 0; + + void ReadBytes(int count) + { + for (int i = 0; i < count; i++) + values.Add(new WinPhonePrimitive(WinPhonePrimitiveKind.Byte, ReadByte(metadataSource, ref position))); + } + void ReadInt16Values(int count) + { + for (int i = 0; i < count; i++) + values.Add(new WinPhonePrimitive(WinPhonePrimitiveKind.Int16, ReadInt16(metadataSource, ref position))); + } + void ReadInt32Values(int count) + { + for (int i = 0; i < count; i++) + { + values.Add(new WinPhonePrimitive( + WinPhonePrimitiveKind.Int32, + ReadInt32(metadataSource, position))); + position += 4; + } + } + + ReadInt32Values(version > 47 ? 3 : 2); + ReadInt16Values(7); + ReadInt32Values(1); + ReadBytes(3); + ReadInt16Values(1); + if (version >= 58) + ReadBytes(2); + ReadInt16Values(2); + + if (version >= 58) + { + ReadBytes(1); + ReadBytes(1); + ReadInt32Values(3); + } + + ReadBytes(9); + if (version > 51) + ReadBytes(1); + if (version > 56) + ReadBytes(1); + if (version > 57) + ReadBytes(8); + + ReadBytes(3); + ReadInt32Values(1); + if (version > 57) + ReadInt16Values(3); + ReadBytes(1); + if (version > 55) + ReadInt16Values(1); + ReadBytes(1); + ReadInt16Values(1); + ReadBytes(1); + ReadInt32Values(1); + + if (version > 57) + { + for (int block = 0; block < 2; block++) + { + ReadBytes(4); + ReadInt16Values(3); + } + ReadBytes(9); + } + + if (position != metadataSource.Length) + throw new InvalidDataException( + $"Windows Phone metadata layout consumed {position} bytes, expected {metadataSource.Length}."); + return [.. values]; + } + + private static void ApplyWinPhoneMetadataToWorld(World world, uint version) + { + WinPhonePrimitive[] values = world.WinPhoneMetadataPrimitives!; + world.WorldId = values[0].Value; + world.RightWorld = values[2].Value; + world.BottomWorld = values[3].Value; + world.TilesHigh = values[4].Value; + world.TilesWide = values[5].Value; + world.DungeonX = values[6].Value; + world.DungeonY = values[7].Value; + world.GroundLevel = values[8].Value; + world.RockLevel = values[9].Value; + world.Time = BitConverter.Int32BitsToSingle(values[10].Value); + world.DayTime = values[11].Value != 0; + world.MoonPhase = values[12].Value; + world.BloodMoon = values[13].Value != 0; + + int spawnIndex = version >= 58 ? 17 : 15; + world.SpawnX = values[spawnIndex].Value; + world.SpawnY = values[spawnIndex + 1].Value; + if (version >= 58) + { + world.IsCrimson = values[19].Value != 0; + world.IsRaining = values[20].Value != 0; + world.TempRainTime = values[21].Value; + world.TempMaxRain = BitConverter.Int32BitsToSingle(values[22].Value); + } + + int progressionIndex = version >= 58 ? 24 : 17; + world.DownedBoss1EyeofCthulhu = values[progressionIndex].Value != 0; + world.DownedBoss2EaterofWorlds = values[progressionIndex + 1].Value != 0; + world.DownedBoss3Skeletron = values[progressionIndex + 2].Value != 0; + world.SavedGoblin = values[progressionIndex + 3].Value != 0; + world.SavedWizard = values[progressionIndex + 4].Value != 0; + world.SavedMech = values[progressionIndex + 5].Value != 0; + world.DownedGoblins = values[progressionIndex + 6].Value != 0; + world.DownedClown = values[progressionIndex + 7].Value != 0; + world.DownedFrost = values[progressionIndex + 8].Value != 0; + if (version >= 58) + { + // Slots +9 and +10 are the mobile-only Lepus and Turkor flags. + world.DownedQueenBee = values[progressionIndex + 11].Value != 0; + world.DownedMechBoss1TheDestroyer = values[progressionIndex + 12].Value != 0; + world.DownedMechBoss2TheTwins = values[progressionIndex + 13].Value != 0; + world.DownedMechBoss3SkeletronPrime = values[progressionIndex + 14].Value != 0; + // Slot +15 is the native derived "any mechanical boss" flag. + world.DownedPlantBoss = values[progressionIndex + 16].Value != 0; + world.DownedGolemBoss = values[progressionIndex + 17].Value != 0; + world.DownedPirates = values[progressionIndex + 18].Value != 0; + } + + int worldStateIndex = version >= 58 ? 43 : 26; + world.ShadowOrbCount = values[worldStateIndex + 2].Value; + // ShadowOrbCount updates ShadowOrbSmashed in the editor model, so apply + // the independently stored native flag after the count. + world.ShadowOrbSmashed = values[worldStateIndex].Value != 0; + world.SpawnMeteor = values[worldStateIndex + 1].Value != 0; + world.AltarCount = values[worldStateIndex + 3].Value; + if (version >= 58) + { + world.SavedOreTiersCobalt = values[worldStateIndex + 4].Value; + world.SavedOreTiersMythril = values[worldStateIndex + 5].Value; + world.SavedOreTiersAdamantite = values[worldStateIndex + 6].Value; + } + + int hardModeIndex = worldStateIndex + (version >= 58 ? 7 : 4); + world.HardMode = values[hardModeIndex].Value != 0; + int invasionIndex = hardModeIndex + (version > 55 ? 2 : 1); + world.InvasionDelay = values[invasionIndex].Value; + world.InvasionSize = values[invasionIndex + 1].Value; + world.InvasionType = values[invasionIndex + 2].Value; + world.InvasionX = BitConverter.Int32BitsToSingle(values[invasionIndex + 3].Value); + } + + private static byte ReadByte(ReadOnlySpan source, ref int position) + { + if ((uint)position >= (uint)source.Length) + throw new EndOfStreamException(); + return source[position++]; + } + + private static ushort ReadUInt16(ReadOnlySpan source, ref int position) + { + if (position + 2 > source.Length) + throw new EndOfStreamException(); + ushort value = (ushort)(source[position] | source[position + 1] << 8); + position += 2; + return value; + } + + private static short ReadInt16(ReadOnlySpan source, ref int position) => + unchecked((short)ReadUInt16(source, ref position)); + + private static float ReadSingle(ReadOnlySpan source, ref int position) + { + if (position + 4 > source.Length) + throw new EndOfStreamException(); + float value = BitConverter.Int32BitsToSingle(ReadInt32(source, position)); + position += 4; + return value; + } + + private static byte[] SerializeWinPhoneDataSections(World world, uint version) + { + if (world.WinPhoneChestSlots is null || world.WinPhoneSignSlots is null || + world.WinPhoneSignTextFlags is null) + throw new InvalidOperationException("Windows Phone sparse section slots are unavailable."); + + SynchronizeWinPhoneSparseSlots(world); + + using MemoryStream stream = new(); + using BinaryWriter writer = new(stream, Encoding.UTF8, leaveOpen: true); + WriteWinPhoneChests(writer, world, version); + writer.Write(0x162E); + WriteWinPhoneSigns(writer, world, version); + writer.Write(0x162E); + WriteWinPhoneNpcs(writer, world); + writer.Write(0x162E); + WriteWinPhoneCharacterNames(writer, world, version); + writer.Flush(); + return stream.ToArray(); + } + + private static void SynchronizeWinPhoneSparseSlots(World world) + { + SynchronizeWinPhoneSlots(world.WinPhoneChestSlots!, world.Chests, "chests"); + + Sign?[] signSlots = world.WinPhoneSignSlots!; + HashSet previousSigns = new(ReferenceEqualityComparer.Instance); + foreach (Sign? sign in signSlots) + if (sign is not null) + previousSigns.Add(sign); + SynchronizeWinPhoneSlots(signSlots, world.Signs, "signs"); + for (int index = 0; index < signSlots.Length; index++) + { + Sign? sign = signSlots[index]; + if (sign is null) + { + world.WinPhoneSignTextFlags![index] = 0; + } + else if (!previousSigns.Contains(sign)) + { + world.WinPhoneSignTextFlags![index] = string.IsNullOrEmpty(sign.Text) ? (byte)0 : (byte)1; + } + } + } + + private static void SynchronizeWinPhoneSlots(T?[] slots, IList items, string sectionName) + where T : class + { + HashSet current = new(items, ReferenceEqualityComparer.Instance); + HashSet slotted = new(ReferenceEqualityComparer.Instance); + for (int index = 0; index < slots.Length; index++) + { + T? item = slots[index]; + if (item is not null && current.Contains(item) && slotted.Add(item)) + continue; + slots[index] = null; + } + + foreach (T item in items) + { + if (slotted.Contains(item)) + continue; + int freeIndex = Array.FindIndex(slots, static slot => slot is null); + if (freeIndex < 0) + break; + slots[freeIndex] = item; + slotted.Add(item); + } + } + + private static byte[] SerializeWinPhoneTiles(World world, uint version) + { + if (world.WinPhoneStoredTileTypes is null || world.WinPhoneTileHeader1 is null || + world.WinPhoneTileHeader2 is null || world.WinPhoneTileShape is null || + world.WinPhoneLegacyTileFlags is null || world.WinPhoneTileRunLengths is null) + throw new InvalidOperationException("Windows Phone tile encoding metadata is unavailable."); + + using MemoryStream stream = new(); + using BinaryWriter writer = new(stream, Encoding.UTF8, leaveOpen: true); + bool[] frameImportant = CreateWinPhoneFrameImportant(version); + for (int x = 0; x < world.TilesWide; x++) + { + for (int y = 0; y < world.TilesHigh;) + { + int index = GetWinPhoneTileIndex(world, x, y); + int run = world.WinPhoneTileRunLengths[index]; + if (y + run >= world.TilesHigh) + throw new InvalidDataException($"Stored Windows Phone tile run exceeds column {x}."); + for (int repeat = 1; repeat <= run; repeat++) + { + if (!WinPhoneTilesEncodeEqual(world, x, y, y + repeat)) + throw new InvalidDataException( + $"Stored Windows Phone tile run no longer matches at {x}, {y + repeat}."); + } + + if (version < 58) + WriteWinPhoneLegacyTile(writer, world, frameImportant, x, y, run, version); + else + WriteWinPhoneCompactTile(writer, world, frameImportant, x, y, run, version); + y += run + 1; + } + } + writer.Flush(); + return stream.ToArray(); + } + + private static byte[] SerializeWinPhonePrefix(World world) + { + if (world.WinPhoneMetadataPrimitives is null || world.WinPhoneHistory is null) + throw new InvalidOperationException("Windows Phone metadata is unavailable."); + + string title = TruncateWinPhoneString(world.Title, world.Version, WinPhoneMaxTitleLength); + byte[] titleBytes = (world.Version < 50 ? Encoding.UTF8 : Encoding.Unicode).GetBytes(title); + int titleLength = world.Version < 50 ? titleBytes.Length : title.Length; + + WinPhonePrimitive[] metadata = CreateWinPhoneMetadataForSave(world); + + using MemoryStream stream = new(); + using BinaryWriter writer = new(stream, Encoding.UTF8, leaveOpen: true); + writer.Write(world.Version); + writer.Write(world.WinPhoneStoredCrc); + writer.Write(titleLength); + writer.Write(titleBytes); + if (world.Version >= 53) + { + writer.Write(world.WinPhoneCloudFlag); + if (world.Version == 53) + { + writer.Write(world.WinPhoneHistory[0]); + } + else + { + writer.Write(world.WinPhoneHistory.Length); + foreach (uint value in world.WinPhoneHistory) + writer.Write(value); + } + } + + foreach (WinPhonePrimitive value in metadata) + { + switch (value.Kind) + { + case WinPhonePrimitiveKind.Byte: + writer.Write((byte)value.Value); + break; + case WinPhonePrimitiveKind.Int16: + writer.Write((short)value.Value); + break; + case WinPhonePrimitiveKind.Int32: + writer.Write(value.Value); + break; + default: + throw new InvalidDataException($"Unknown Windows Phone primitive kind {value.Kind}."); + } + } + writer.Write(0x162E); + writer.Flush(); + return stream.ToArray(); + } + + private static WinPhonePrimitive[] CreateWinPhoneMetadataForSave(World world) + { + WinPhonePrimitive[] values = (WinPhonePrimitive[])world.WinPhoneMetadataPrimitives!.Clone(); + SetWinPhonePrimitive(values, 0, WinPhonePrimitiveKind.Int32, world.WorldId, nameof(world.WorldId)); + SetWinPhonePrimitive(values, 2, WinPhonePrimitiveKind.Int32, + checked(world.TilesWide * 16), nameof(world.RightWorld)); + SetWinPhonePrimitive(values, 3, WinPhonePrimitiveKind.Int16, + checked((short)(world.TilesHigh * 16)), nameof(world.BottomWorld)); + SetWinPhonePrimitive(values, 4, WinPhonePrimitiveKind.Int16, + ToWinPhoneInt16(world.TilesHigh, nameof(world.TilesHigh)), nameof(world.TilesHigh)); + SetWinPhonePrimitive(values, 5, WinPhonePrimitiveKind.Int16, + ToWinPhoneInt16(world.TilesWide, nameof(world.TilesWide)), nameof(world.TilesWide)); + SetWinPhonePrimitive(values, 6, WinPhonePrimitiveKind.Int16, + ToWinPhoneInt16(world.DungeonX, nameof(world.DungeonX)), nameof(world.DungeonX)); + SetWinPhonePrimitive(values, 7, WinPhonePrimitiveKind.Int16, + ToWinPhoneInt16(world.DungeonY, nameof(world.DungeonY)), nameof(world.DungeonY)); + SetWinPhonePrimitive(values, 8, WinPhonePrimitiveKind.Int16, + ToWinPhoneInt16(world.GroundLevel, nameof(world.GroundLevel)), nameof(world.GroundLevel)); + SetWinPhonePrimitive(values, 9, WinPhonePrimitiveKind.Int16, + ToWinPhoneInt16(world.RockLevel, nameof(world.RockLevel)), nameof(world.RockLevel)); + SetWinPhonePrimitive(values, 10, WinPhonePrimitiveKind.Int32, + BitConverter.SingleToInt32Bits(checked((float)world.Time)), nameof(world.Time)); + SetWinPhonePrimitive(values, 11, WinPhonePrimitiveKind.Byte, + world.DayTime ? 1 : 0, nameof(world.DayTime)); + SetWinPhonePrimitive(values, 12, WinPhonePrimitiveKind.Byte, + ToWinPhoneByte(world.MoonPhase, nameof(world.MoonPhase)), nameof(world.MoonPhase)); + SetWinPhonePrimitive(values, 13, WinPhonePrimitiveKind.Byte, + world.BloodMoon ? 1 : 0, nameof(world.BloodMoon)); + + int spawnIndex = world.Version >= 58 ? 17 : 15; + SetWinPhonePrimitive(values, spawnIndex, WinPhonePrimitiveKind.Int16, + ToWinPhoneInt16(world.SpawnX, nameof(world.SpawnX)), nameof(world.SpawnX)); + SetWinPhonePrimitive(values, spawnIndex + 1, WinPhonePrimitiveKind.Int16, + ToWinPhoneInt16(world.SpawnY, nameof(world.SpawnY)), nameof(world.SpawnY)); + if (world.Version >= 58) + { + SetWinPhonePrimitive(values, 19, WinPhonePrimitiveKind.Byte, + world.IsCrimson ? 1 : 0, nameof(world.IsCrimson)); + SetWinPhonePrimitive(values, 20, WinPhonePrimitiveKind.Byte, + world.IsRaining ? 1 : 0, nameof(world.IsRaining)); + SetWinPhonePrimitive(values, 21, WinPhonePrimitiveKind.Int32, + Math.Max(0, world.TempRainTime), nameof(world.TempRainTime)); + SetWinPhonePrimitive(values, 22, WinPhonePrimitiveKind.Int32, + BitConverter.SingleToInt32Bits(float.IsFinite(world.TempMaxRain) + ? Math.Clamp(world.TempMaxRain, 0, 1) + : 0), nameof(world.TempMaxRain)); + } + + int progressionIndex = world.Version >= 58 ? 24 : 17; + SetWinPhoneBoolean(values, progressionIndex, world.DownedBoss1EyeofCthulhu, + nameof(world.DownedBoss1EyeofCthulhu)); + SetWinPhoneBoolean(values, progressionIndex + 1, world.DownedBoss2EaterofWorlds, + nameof(world.DownedBoss2EaterofWorlds)); + SetWinPhoneBoolean(values, progressionIndex + 2, world.DownedBoss3Skeletron, + nameof(world.DownedBoss3Skeletron)); + SetWinPhoneBoolean(values, progressionIndex + 3, world.SavedGoblin, nameof(world.SavedGoblin)); + SetWinPhoneBoolean(values, progressionIndex + 4, world.SavedWizard, nameof(world.SavedWizard)); + SetWinPhoneBoolean(values, progressionIndex + 5, world.SavedMech, nameof(world.SavedMech)); + SetWinPhoneBoolean(values, progressionIndex + 6, world.DownedGoblins, nameof(world.DownedGoblins)); + SetWinPhoneBoolean(values, progressionIndex + 7, world.DownedClown, nameof(world.DownedClown)); + SetWinPhoneBoolean(values, progressionIndex + 8, world.DownedFrost, nameof(world.DownedFrost)); + if (world.Version >= 58) + { + SetWinPhoneBoolean(values, progressionIndex + 11, world.DownedQueenBee, nameof(world.DownedQueenBee)); + SetWinPhoneBoolean(values, progressionIndex + 12, world.DownedMechBoss1TheDestroyer, + nameof(world.DownedMechBoss1TheDestroyer)); + SetWinPhoneBoolean(values, progressionIndex + 13, world.DownedMechBoss2TheTwins, + nameof(world.DownedMechBoss2TheTwins)); + SetWinPhoneBoolean(values, progressionIndex + 14, world.DownedMechBoss3SkeletronPrime, + nameof(world.DownedMechBoss3SkeletronPrime)); + SetWinPhoneBoolean(values, progressionIndex + 15, world.DownedMechBossAny, + nameof(world.DownedMechBossAny)); + SetWinPhoneBoolean(values, progressionIndex + 16, world.DownedPlantBoss, nameof(world.DownedPlantBoss)); + SetWinPhoneBoolean(values, progressionIndex + 17, world.DownedGolemBoss, nameof(world.DownedGolemBoss)); + SetWinPhoneBoolean(values, progressionIndex + 18, world.DownedPirates, nameof(world.DownedPirates)); + } + + int worldStateIndex = world.Version >= 58 ? 43 : 26; + SetWinPhoneBoolean(values, worldStateIndex, world.ShadowOrbSmashed, nameof(world.ShadowOrbSmashed)); + SetWinPhoneBoolean(values, worldStateIndex + 1, world.SpawnMeteor, nameof(world.SpawnMeteor)); + SetWinPhonePrimitive(values, worldStateIndex + 2, WinPhonePrimitiveKind.Byte, + ToWinPhoneByte(world.ShadowOrbCount, nameof(world.ShadowOrbCount)), nameof(world.ShadowOrbCount)); + SetWinPhonePrimitive(values, worldStateIndex + 3, WinPhonePrimitiveKind.Int32, + world.AltarCount, nameof(world.AltarCount)); + if (world.Version >= 58) + { + SetWinPhonePrimitive(values, worldStateIndex + 4, WinPhonePrimitiveKind.Int16, + ToWinPhoneInt16(world.SavedOreTiersCobalt, nameof(world.SavedOreTiersCobalt)), + nameof(world.SavedOreTiersCobalt)); + SetWinPhonePrimitive(values, worldStateIndex + 5, WinPhonePrimitiveKind.Int16, + ToWinPhoneInt16(world.SavedOreTiersMythril, nameof(world.SavedOreTiersMythril)), + nameof(world.SavedOreTiersMythril)); + SetWinPhonePrimitive(values, worldStateIndex + 6, WinPhonePrimitiveKind.Int16, + ToWinPhoneInt16(world.SavedOreTiersAdamantite, nameof(world.SavedOreTiersAdamantite)), + nameof(world.SavedOreTiersAdamantite)); + } + + int hardModeIndex = worldStateIndex + (world.Version >= 58 ? 7 : 4); + SetWinPhoneBoolean(values, hardModeIndex, world.HardMode, nameof(world.HardMode)); + int invasionIndex = hardModeIndex + (world.Version > 55 ? 2 : 1); + SetWinPhonePrimitive(values, invasionIndex, WinPhonePrimitiveKind.Byte, + ToWinPhoneByte(world.InvasionDelay, nameof(world.InvasionDelay)), nameof(world.InvasionDelay)); + SetWinPhonePrimitive(values, invasionIndex + 1, WinPhonePrimitiveKind.Int16, + ToWinPhoneInt16(world.InvasionSize, nameof(world.InvasionSize)), nameof(world.InvasionSize)); + SetWinPhonePrimitive(values, invasionIndex + 2, WinPhonePrimitiveKind.Byte, + ToWinPhoneByte(world.InvasionType, nameof(world.InvasionType)), nameof(world.InvasionType)); + if (!double.IsFinite(world.InvasionX) || world.InvasionX is < -float.MaxValue or > float.MaxValue) + throw new InvalidDataException("Windows Phone invasion position must be a finite 32-bit number."); + SetWinPhonePrimitive(values, invasionIndex + 3, WinPhonePrimitiveKind.Int32, + BitConverter.SingleToInt32Bits((float)world.InvasionX), nameof(world.InvasionX)); + return values; + } + + private static void SetWinPhoneBoolean( + WinPhonePrimitive[] values, + int index, + bool value, + string fieldName) => + SetWinPhonePrimitive(values, index, WinPhonePrimitiveKind.Byte, value ? 1 : 0, fieldName); + + private static void SetWinPhonePrimitive( + WinPhonePrimitive[] values, + int index, + WinPhonePrimitiveKind expectedKind, + int value, + string fieldName) + { + if ((uint)index >= (uint)values.Length || values[index].Kind != expectedKind) + throw new InvalidDataException($"Windows Phone metadata layout does not contain {fieldName}."); + values[index] = new WinPhonePrimitive(expectedKind, value); + } + + private static byte ToWinPhoneByte(int value, string fieldName) + => (byte)Math.Clamp(value, byte.MinValue, byte.MaxValue); + + private static short ToWinPhoneInt16(double value, string fieldName) + { + if (!double.IsFinite(value)) + return 0; + return (short)Math.Clamp(Math.Truncate(value), short.MinValue, short.MaxValue); + } + + private static int ToWinPhoneInt32(double value, string fieldName) + { + if (!double.IsFinite(value)) + return 0; + return (int)Math.Clamp(Math.Truncate(value), int.MinValue, int.MaxValue); + } + + private static bool WinPhoneTilesEncodeEqual(World world, int x, int firstY, int secondY) + { + if (!world.Tiles[x, firstY].Equals(world.Tiles[x, secondY])) + return false; + int first = GetWinPhoneTileIndex(world, x, firstY); + int second = GetWinPhoneTileIndex(world, x, secondY); + return world.WinPhoneStoredTileTypes![first] == world.WinPhoneStoredTileTypes[second] && + world.WinPhoneTileHeader1![first] == world.WinPhoneTileHeader1[second] && + world.WinPhoneTileHeader2![first] == world.WinPhoneTileHeader2[second] && + world.WinPhoneTileShape![first] == world.WinPhoneTileShape[second] && + world.WinPhoneLegacyTileFlags![first] == world.WinPhoneLegacyTileFlags[second]; + } + + private static bool PrepareWinPhoneTilesForSave(World world, World original, uint version) + { + bool anyChanges = false; + for (int x = 0; x < world.TilesWide; x++) + { + bool changedColumn = false; + for (int y = 0; y < world.TilesHigh && !changedColumn; y++) + changedColumn = !world.Tiles[x, y].Equals(original.Tiles[x, y]); + if (!changedColumn) + continue; + anyChanges = true; + + for (int y = 0; y < world.TilesHigh; y++) + { + int index = GetWinPhoneTileIndex(world, x, y); + world.WinPhoneTileRunLengths![index] = 0; + if (!world.Tiles[x, y].Equals(original.Tiles[x, y])) + UpdateWinPhoneTileEncodingMetadata(world, version, x, y); + } + } + return anyChanges; + } + + private static void UpdateWinPhoneTileEncodingMetadata(World world, uint version, int x, int y) + { + int index = GetWinPhoneTileIndex(world, x, y); + Tile tile = world.Tiles[x, y]; + if (version < 58) + { + ushort storedType = tile.Type == 500 ? (ushort)150 : tile.Type; + if (storedType > byte.MaxValue) + storedType = 0; + world.WinPhoneStoredTileTypes![index] = storedType; + world.WinPhoneTileHeader1![index] = tile.IsActive && storedType != 0 ? (byte)1 : (byte)0; + world.WinPhoneLegacyTileFlags![index] = (byte)( + (world.WinPhoneLegacyTileFlags[index] & ~0x70) | + (tile.WireRed ? 0x10 : 0) | + (tile.WireGreen ? 0x20 : 0) | + (tile.WireBlue ? 0x40 : 0)); + return; + } + + byte header1 = (byte)(world.WinPhoneTileHeader1![index] & 0xC0); + if (tile.IsActive && tile.Type <= 0x1FF) header1 |= 0x01; + if (tile.WireRed) header1 |= 0x02; + if (tile.WireGreen) header1 |= 0x04; + if (tile.WireBlue) header1 |= 0x08; + if (tile.Actuator) header1 |= 0x10; + if (tile.InActive) header1 |= 0x20; + world.WinPhoneTileHeader1[index] = header1; + world.WinPhoneStoredTileTypes![index] = tile.Type <= 0x1FF ? tile.Type : (ushort)0; + + byte header2 = (byte)(world.WinPhoneTileHeader2![index] & ~0x10); + if (tile.WireYellow) header2 |= 0x10; + world.WinPhoneTileHeader2[index] = header2; + world.WinPhoneTileShape![index] = (byte)( + (world.WinPhoneTileShape[index] & 0x0F) | ((byte)tile.BrickStyle << 4)); + } + + private static void WriteWinPhoneLegacyTile( + BinaryWriter writer, + World world, + bool[] frameImportant, + int x, + int y, + int run, + uint version) + { + int index = GetWinPhoneTileIndex(world, x, y); + Tile tile = world.Tiles[x, y]; + bool storedActive = world.WinPhoneTileHeader1![index] != 0; + writer.Write(storedActive); + if (storedActive) + { + byte storedType = (byte)world.WinPhoneStoredTileTypes![index]; + writer.Write(storedType); + if (IsWinPhoneLegacyFramed(storedType)) + { + writer.Write(tile.U); + short storedV = tile.V; + if (storedType == 35) + storedV -= 54; + else if (storedType == 36) + storedV -= 108; + writer.Write(storedV); + } + } + byte wall = tile.Wall <= byte.MaxValue ? (byte)tile.Wall : (byte)0; + writer.Write(wall); + if (version > 50) + writer.Write(tile.WallColor); + byte liquidAmount = tile.LiquidType is LiquidType.Water or LiquidType.Lava + ? tile.LiquidAmount + : (byte)0; + writer.Write(liquidAmount); + if (liquidAmount != 0) + writer.Write(tile.LiquidType == LiquidType.Lava); + writer.Write(world.WinPhoneLegacyTileFlags![index]); + writer.Write((short)run); + } + + private static void WriteWinPhoneCompactTile( + BinaryWriter writer, + World world, + bool[] frameImportant, + int x, + int y, + int run, + uint version) + { + int index = GetWinPhoneTileIndex(world, x, y); + Tile tile = world.Tiles[x, y]; + byte header1 = world.WinPhoneTileHeader1![index]; + writer.Write(header1); + if ((header1 & 1) != 0) + { + ushort storedType = world.WinPhoneStoredTileTypes![index]; + writer.Write(storedType); + if (storedType < frameImportant.Length && frameImportant[storedType]) + { + writer.Write(tile.U); + writer.Write(tile.V); + } + writer.Write(tile.TileColor); + } + byte wall = tile.Wall <= byte.MaxValue ? (byte)tile.Wall : (byte)0; + writer.Write(wall); + if (wall != 0) + writer.Write(tile.WallColor); + byte liquidAmount = tile.LiquidType is LiquidType.Water or LiquidType.Lava or LiquidType.Honey + ? tile.LiquidAmount + : (byte)0; + writer.Write(liquidAmount); + if (liquidAmount != 0) + writer.Write((byte)((int)tile.LiquidType - 1)); + writer.Write(world.WinPhoneTileHeader2![index]); + if (version is >= 60 and <= 68) + writer.Write(world.WinPhoneTileShape![index]); + if (run < 0x80) + { + writer.Write((byte)run); + } + else + { + writer.Write((byte)((run & 0x7F) | 0x80)); + writer.Write((byte)(run >> 7)); + } + } + + private static void WriteWinPhoneChests(BinaryWriter writer, World world, uint version) + { + Chest?[] slots = world.WinPhoneChestSlots!; + int itemCount = version < 58 ? 20 : 40; + byte[] itemMask = new byte[5]; + for (int chestIndex = 0; chestIndex < Chest.LegacyLimit; chestIndex++) + { + Chest? chest = slots[chestIndex]; + bool canWrite = chest is not null && + chest.X is >= short.MinValue and <= short.MaxValue && + chest.Y is >= short.MinValue and <= short.MaxValue; + writer.Write(canWrite); + if (!canWrite) + continue; + + writer.Write(ToWinPhoneInt16(chest.X, "Chest X")); + writer.Write(ToWinPhoneInt16(chest.Y, "Chest Y")); + if (version < 58) + { + for (int slot = 0; slot < itemCount; slot++) + WriteWinPhoneChestItem(writer, chest.Items[slot], version); + continue; + } + + Array.Clear(itemMask); + for (int slot = 0; slot < itemCount; slot++) + { + Item item = chest.Items[slot]; + if (item.StackSize > 0 && item.NetId != 0) + itemMask[slot >> 3] |= (byte)(1 << (slot & 7)); + } + writer.Write((byte)itemMask.Length); + writer.Write(itemMask); + for (int slot = 0; slot < itemCount; slot++) + { + if ((itemMask[slot >> 3] & 1 << (slot & 7)) != 0) + WriteWinPhoneChestItem(writer, chest.Items[slot], version); + } + } + } + + private static void WriteWinPhoneChestItem(BinaryWriter writer, Item item, uint version) + { + int stack = item.NetId == 0 ? 0 : item.StackSize; + int maxStack = version < 55 ? byte.MaxValue : short.MaxValue; + if (item.NetId is < short.MinValue or > short.MaxValue) + stack = 0; + stack = Math.Clamp(stack, 0, maxStack); + if (version < 55) + writer.Write((byte)stack); + else + writer.Write((short)stack); + if (stack <= 0) + return; + writer.Write((short)item.NetId); + writer.Write(item.Prefix); + } + + private static void WriteWinPhoneSigns(BinaryWriter writer, World world, uint version) + { + Sign?[] slots = world.WinPhoneSignSlots!; + for (int signIndex = 0; signIndex < Sign.LegacyLimit; signIndex++) + { + Sign? sign = slots[signIndex]; + bool canWrite = sign is not null && + sign.X is >= short.MinValue and <= short.MaxValue && + sign.Y is >= short.MinValue and <= short.MaxValue; + writer.Write(canWrite); + if (!canWrite) + continue; + writer.Write(ToWinPhoneInt16(sign.X, "Sign X")); + writer.Write(ToWinPhoneInt16(sign.Y, "Sign Y")); + if (version > 57) + writer.Write(world.WinPhoneSignTextFlags![signIndex]); + WriteWinPhoneString(writer, sign.Text, version); + } + } + + private static void WriteWinPhoneNpcs(BinaryWriter writer, World world) + { + foreach (NPC npc in world.NPCs) + { + if (npc.SpriteId is < byte.MinValue or > byte.MaxValue) + continue; + if (!float.IsFinite(npc.Position.X) || !float.IsFinite(npc.Position.Y)) + continue; + writer.Write(true); + writer.Write((byte)npc.SpriteId); + writer.Write(npc.Position.X); + writer.Write(npc.Position.Y); + writer.Write(npc.IsHomeless); + writer.Write(ToWinPhoneInt16(npc.Home.X, "NPC home X")); + writer.Write(ToWinPhoneInt16(npc.Home.Y, "NPC home Y")); + } + writer.Write(false); + } + + private static void WriteWinPhoneCharacterNames(BinaryWriter writer, World world, uint version) + { + int nameCount = version < 50 ? 10 : WinPhoneCharacterNameIds.Length; + for (int index = 0; index < nameCount; index++) + { + int id = WinPhoneCharacterNameIds[index]; + string name = string.Empty; + foreach (NpcName candidate in world.CharacterNames) + { + if (candidate.Id != id) + continue; + name = candidate.Name; + break; + } + WriteWinPhoneString(writer, name, version); + } + } + + private static void WriteWinPhoneString(BinaryWriter writer, string value, uint version) + { + value ??= string.Empty; + byte[] bytes = (version < 50 ? Encoding.UTF8 : Encoding.Unicode).GetBytes(value); + writer.Write(version < 50 ? bytes.Length : value.Length); + writer.Write(bytes); + } + + private static string TruncateWinPhoneString(string? value, uint version, int maxLength) + { + value ??= string.Empty; + if (version >= 50) + { + if (value.Length <= maxLength) + return value; + int utf16Length = maxLength; + if (char.IsHighSurrogate(value[utf16Length - 1])) + utf16Length--; + return value[..utf16Length]; + } + + if (Encoding.UTF8.GetByteCount(value) <= maxLength) + return value; + int length = value.Length; + while (length > 0 && Encoding.UTF8.GetByteCount(value.AsSpan(0, length)) > maxLength) + length--; + if (length > 0 && char.IsHighSurrogate(value[length - 1])) + length--; + return value[..length]; + } + + private static WinPhonePrimitive[] CreateWinPhoneMetadataLayout(uint version) + { + List values = []; + void Add(WinPhonePrimitiveKind kind, int count) + { + for (int index = 0; index < count; index++) + values.Add(new WinPhonePrimitive(kind, 0)); + } + + Add(WinPhonePrimitiveKind.Int32, version > 47 ? 3 : 2); + Add(WinPhonePrimitiveKind.Int16, 7); + Add(WinPhonePrimitiveKind.Int32, 1); + Add(WinPhonePrimitiveKind.Byte, 3); + Add(WinPhonePrimitiveKind.Int16, 1); + if (version >= 58) Add(WinPhonePrimitiveKind.Byte, 2); + Add(WinPhonePrimitiveKind.Int16, 2); + if (version >= 58) + { + Add(WinPhonePrimitiveKind.Byte, 2); + Add(WinPhonePrimitiveKind.Int32, 3); + } + Add(WinPhonePrimitiveKind.Byte, 9); + if (version > 51) Add(WinPhonePrimitiveKind.Byte, 1); + if (version > 56) Add(WinPhonePrimitiveKind.Byte, 1); + if (version > 57) Add(WinPhonePrimitiveKind.Byte, 8); + Add(WinPhonePrimitiveKind.Byte, 3); + Add(WinPhonePrimitiveKind.Int32, 1); + if (version > 57) Add(WinPhonePrimitiveKind.Int16, 3); + Add(WinPhonePrimitiveKind.Byte, 1); + if (version > 55) Add(WinPhonePrimitiveKind.Int16, 1); + Add(WinPhonePrimitiveKind.Byte, 1); + Add(WinPhonePrimitiveKind.Int16, 1); + Add(WinPhonePrimitiveKind.Byte, 1); + Add(WinPhonePrimitiveKind.Int32, 1); + if (version > 57) + { + for (int block = 0; block < 2; block++) + { + Add(WinPhonePrimitiveKind.Byte, 4); + Add(WinPhonePrimitiveKind.Int16, 3); + } + Add(WinPhonePrimitiveKind.Byte, 9); + } + return [.. values]; + } + + internal static void SaveWinPhoneConverted(World world, Stream output) + { + ArgumentNullException.ThrowIfNull(world); + ArgumentNullException.ThrowIfNull(output); + output.Write(BuildWinPhoneConverted(world)); + } + + private static byte[] BuildWinPhoneConverted(World world) + { + if (world.TilesWide is <= 0 or > WinPhoneMaxWorldWidth || + world.TilesHigh is <= 0 or > WinPhoneMaxWorldHeight) + throw new NotSupportedException( + $"Windows Phone worlds cannot exceed {WinPhoneMaxWorldWidth} × {WinPhoneMaxWorldHeight} tiles."); + if (world.Tiles is null || world.Tiles.Length != world.TilesWide * world.TilesHigh) + throw new InvalidOperationException("Load the complete world before converting it to Windows Phone."); + + const uint version = 60; + uint originalVersion = world.Version; + try + { + world.Version = version; + world.WinPhoneMetadataPrimitives = CreateWinPhoneMetadataLayout(version); + world.WinPhoneHistory = []; + world.WinPhoneStoredCrc = 1; + world.WinPhoneCloudFlag = 0; + world.WinPhoneChestSlots = new Chest?[Chest.LegacyLimit]; + world.WinPhoneSignSlots = new Sign?[Sign.LegacyLimit]; + world.WinPhoneSignTextFlags = new byte[Sign.LegacyLimit]; + + int tileCount = checked(world.TilesWide * world.TilesHigh); + world.WinPhoneStoredTileTypes = new ushort[tileCount]; + world.WinPhoneTileHeader1 = new byte[tileCount]; + world.WinPhoneTileHeader2 = new byte[tileCount]; + world.WinPhoneTileShape = new byte[tileCount]; + world.WinPhoneLegacyTileFlags = new byte[tileCount]; + world.WinPhoneTileRunLengths = new ushort[tileCount]; + for (int x = 0; x < world.TilesWide; x++) + for (int y = 0; y < world.TilesHigh; y++) + UpdateWinPhoneTileEncodingMetadata(world, version, x, y); + + byte[] prefix = SerializeWinPhonePrefix(world); + byte[] tiles = SerializeWinPhoneTiles(world, version); + byte[] dataSections = SerializeWinPhoneDataSections(world, version); + using MemoryStream native = new(); + native.Write(prefix); + native.Write(tiles); + using (BinaryWriter writer = new(native, Encoding.UTF8, leaveOpen: true)) + writer.Write(0x162E); + native.Write(dataSections); + + byte[] result = native.ToArray(); + uint crc = ComputeWinPhoneCrc32(result.AsSpan(8)); + BitConverter.TryWriteBytes(result.AsSpan(4, 4), crc); + return result; + } + finally + { + world.Version = originalVersion; + } + } + + public static async Task SaveWinPhoneAsync( + World world, + string filename, + IProgress? progress = null) + { + await Task.Run(() => + { + lock (_fileLock) + { + string temp = filename + ".tmp"; + progress?.Report(new ProgressChangedEventArgs(0, "Converting world to Windows Phone...")); + byte[] result; + if (world.IsWinPhone && world.Version == 60) + { + using MemoryStream native = new(); + SaveWinPhoneVerifiedUnchanged(world, native); + result = native.ToArray(); + } + else + { + result = BuildWinPhoneConverted(world); + } + using (FileStream stream = new(temp, FileMode.Create)) + stream.Write(result); + File.Copy(temp, filename, true); + File.Delete(temp); + AdoptWinPhoneContainerState(world, result); + world.LastSave = File.GetLastWriteTimeUtc(filename); + progress?.Report(new ProgressChangedEventArgs(100, "World Save Complete.")); + } + }); + } + + private static void AdoptWinPhoneContainerState(World world, byte[] source) + { + World parsed = new(); + using MemoryStream stream = new(source, writable: false); + using BinaryReader reader = new(stream); + if (!TryLoadWinPhone(reader, parsed, headersOnly: false)) + throw new InvalidDataException("The converted Windows Phone world could not be reloaded."); + + world.IsWinPhone = true; + world.Version = parsed.Version; + world.RightWorld = parsed.RightWorld; + world.BottomWorld = parsed.BottomWorld; + world.TileFrameImportant = parsed.TileFrameImportant; + world.WinPhoneSourceData = source; + world.WinPhoneStoredCrc = parsed.WinPhoneStoredCrc; + world.WinPhoneCloudFlag = parsed.WinPhoneCloudFlag; + world.WinPhoneHistory = parsed.WinPhoneHistory; + world.WinPhoneMetadataPrimitives = parsed.WinPhoneMetadataPrimitives; + world.WinPhoneTileSectionOffset = parsed.WinPhoneTileSectionOffset; + world.WinPhoneDataSectionOffset = parsed.WinPhoneDataSectionOffset; + world.WinPhoneParsedLength = parsed.WinPhoneParsedLength; + world.WinPhoneStoredTileTypes = parsed.WinPhoneStoredTileTypes; + world.WinPhoneTileHeader1 = parsed.WinPhoneTileHeader1; + world.WinPhoneTileHeader2 = parsed.WinPhoneTileHeader2; + world.WinPhoneTileShape = parsed.WinPhoneTileShape; + world.WinPhoneLegacyTileFlags = parsed.WinPhoneLegacyTileFlags; + world.WinPhoneTileRunLengths = parsed.WinPhoneTileRunLengths; + world.WinPhoneChestSlots = parsed.WinPhoneChestSlots; + world.WinPhoneSignSlots = parsed.WinPhoneSignSlots; + world.WinPhoneSignTextFlags = parsed.WinPhoneSignTextFlags; + } + + /// + /// Writes the original native container without normalization. This is the + /// lossless path used for an unedited Windows Phone world. + /// + public static void SaveWinPhoneUnchanged(World world, Stream output) + { + ArgumentNullException.ThrowIfNull(world); + ArgumentNullException.ThrowIfNull(output); + + if (!world.IsWinPhone || world.WinPhoneSourceData is null) + throw new InvalidOperationException("The world was not loaded from a Windows Phone world file."); + + output.Write(world.WinPhoneSourceData, 0, world.WinPhoneSourceData.Length); + } + + /// + /// Rebuilds every decoded native section. Only the still-unmapped metadata + /// prefix and the v49 fat-resource suffix are copied as opaque bytes. + /// + internal static void SaveWinPhoneReconstructed(World world, Stream output) + { + ArgumentNullException.ThrowIfNull(world); + ArgumentNullException.ThrowIfNull(output); + if (!world.IsWinPhone || world.WinPhoneSourceData is null) + throw new InvalidOperationException("The world was not loaded from a Windows Phone world file."); + if (world.WinPhoneTileSectionOffset <= 0 || + world.WinPhoneParsedLength < world.WinPhoneDataSectionOffset) + throw new InvalidOperationException("Load the complete Windows Phone world before reconstructing it."); + + byte[] prefix = SerializeWinPhonePrefix(world); + byte[] tiles = SerializeWinPhoneTiles(world, world.Version); + byte[] dataSections = SerializeWinPhoneDataSections(world, world.Version); + using MemoryStream native = new(); + native.Write(prefix); + native.Write(tiles); + using (BinaryWriter writer = new(native, Encoding.UTF8, leaveOpen: true)) + writer.Write(0x162E); + native.Write(dataSections); + native.Write( + world.WinPhoneSourceData, + world.WinPhoneParsedLength, + world.WinPhoneSourceData.Length - world.WinPhoneParsedLength); + + byte[] result = native.ToArray(); + if (world.WinPhoneStoredCrc != 0) + { + uint crc = ComputeWinPhoneCrc32(result.AsSpan(8)); + BitConverter.TryWriteBytes(result.AsSpan(4, 4), crc); + } + output.Write(result); + } + + /// + /// Standard save-path guard. Until edited native serialization is + /// available, only a fully loaded, semantically unchanged world may use + /// the lossless source-container path. + /// + private static void SaveWinPhoneVerifiedUnchanged(World world, Stream output) + { + if (world.WinPhoneSourceData is null || + !TryReadWinPhoneHeader(world.WinPhoneSourceData, out WinPhoneHeader header)) + throw new InvalidOperationException("The original Windows Phone world data is unavailable."); + + if (world.Version != header.Version || world.RightWorld != header.RightWorld || + world.BottomWorld != header.BottomWorld || world.TilesHigh != header.TilesHigh || + world.TilesWide != header.TilesWide) + throw new NotSupportedException( + "Changing Windows Phone world dimensions or native version is not supported yet."); + + if (world.Tiles is null || world.Tiles.Length != world.TilesWide * world.TilesHigh) + throw new InvalidOperationException( + "Load the complete Windows Phone world before saving it through World.Save."); + + World original = new() + { + Version = header.Version, + TilesWide = header.TilesWide, + TilesHigh = header.TilesHigh, + }; + int originalDataOffset = LoadWinPhoneTiles( + world.WinPhoneSourceData, header, original, progress: null); + LoadWinPhoneSections(world.WinPhoneSourceData, header, original, originalDataOffset); + + bool tileEdits = PrepareWinPhoneTilesForSave(world, original, header.Version); + + byte[] serializedTiles = SerializeWinPhoneTiles(world, header.Version); + ReadOnlySpan originalTiles = world.WinPhoneSourceData.AsSpan( + world.WinPhoneTileSectionOffset, + world.WinPhoneDataSectionOffset - world.WinPhoneTileSectionOffset - 4); + if (!tileEdits && !serializedTiles.AsSpan().SequenceEqual(originalTiles)) + { + int commonLength = Math.Min(serializedTiles.Length, originalTiles.Length); + int difference = 0; + while (difference < commonLength && serializedTiles[difference] == originalTiles[difference]) + difference++; + int diagnosticLength = Math.Min(16, commonLength - difference); + throw new InvalidDataException( + $"Windows Phone tile serialization differed at relative offset {difference} " + + $"(serialized length {serializedTiles.Length}, original length {originalTiles.Length}); " + + $"serialized={Convert.ToHexString(serializedTiles.AsSpan(difference, diagnosticLength))}, " + + $"original={Convert.ToHexString(originalTiles.Slice(difference, diagnosticLength))}."); + } + + SaveWinPhoneReconstructed(world, output); + } + + internal static bool TryReadWinPhoneHeader(ReadOnlySpan source, out WinPhoneHeader header) + { + header = default; + if (source.Length < 40) + return false; + + uint version = ReadUInt32(source, 0); + if (version is < 39 or > 65) + return false; + + uint storedCrc = ReadUInt32(source, 4); + int titleLength = ReadInt32(source, 8); + if (titleLength is < 0 or > WinPhoneMaxTitleLength) + return false; + + int titleByteLength = version < 50 ? titleLength : checked(titleLength * 2); + int position = checked(12 + titleByteLength); + if (position > source.Length) + return false; + + string title = version < 50 + ? Encoding.UTF8.GetString(source.Slice(12, titleByteLength)) + : Encoding.Unicode.GetString(source.Slice(12, titleByteLength)); + + byte cloudFlag = 0; + uint[] history = []; + + if (version >= 53) + { + if (position >= source.Length) + return false; + cloudFlag = source[position++]; + if (version == 53) + { + if (position + 4 > source.Length) + return false; + history = [ReadUInt32(source, position)]; + position += 4; + } + else + { + if (position + 4 > source.Length) + return false; + int historyCount = ReadInt32(source, position); + if (historyCount is < 0 or > 100) + return false; + position += 4; + history = new uint[historyCount]; + for (int index = 0; index < historyCount; index++) + { + history[index] = ReadUInt32(source, position); + position += 4; + } + } + } + + // Native body prefix recovered from FUN_0078d0d8. The two pixel bounds + // must exactly match the compact 16-bit tile dimensions; this is the + // discriminator that prevents ordinary desktop V1 files being claimed. + if (position + 26 > source.Length) + return false; + + int worldId = ReadInt32(source, position); + int rightWorld = ReadInt32(source, position + 8); + short bottomWorld = ReadInt16(source, position + 12); + short tilesHigh = ReadInt16(source, position + 14); + short tilesWide = ReadInt16(source, position + 16); + short dungeonX = ReadInt16(source, position + 18); + short dungeonY = ReadInt16(source, position + 20); + short groundLevel = ReadInt16(source, position + 22); + short rockLevel = ReadInt16(source, position + 24); + int spawnOffset = position + 26 + (version >= 58 ? 11 : 9); + if (spawnOffset + 4 > source.Length) + return false; + short spawnX = ReadInt16(source, spawnOffset); + short spawnY = ReadInt16(source, spawnOffset + 2); + + if (tilesWide <= 0 || tilesHigh <= 0 || + rightWorld != tilesWide * 16 || bottomWorld != tilesHigh * 16) + return false; + + // Tutorial v49 intentionally stores zero. Later native saves use the + // standard reflected CRC-32 over every byte after the first eight. + if (storedCrc != 0 && storedCrc != ComputeWinPhoneCrc32(source[8..])) + return false; + + header = new WinPhoneHeader( + version, + storedCrc, + title, + position, + worldId, + rightWorld, + bottomWorld, + tilesHigh, + tilesWide, + dungeonX, + dungeonY, + spawnX, + spawnY, + groundLevel, + rockLevel); + header = header with { CloudFlag = cloudFlag, History = history }; + return true; + } + + private static uint ComputeWinPhoneCrc32(ReadOnlySpan data) + { + uint crc = uint.MaxValue; + foreach (byte value in data) + { + crc ^= value; + for (int bit = 0; bit < 8; bit++) + crc = (crc >> 1) ^ ((crc & 1) != 0 ? 0xEDB88320u : 0u); + } + return ~crc; + } + + private static short ReadInt16(ReadOnlySpan source, int offset) => + (short)(source[offset] | source[offset + 1] << 8); + + private static int ReadInt32(ReadOnlySpan source, int offset) => + source[offset] | + source[offset + 1] << 8 | + source[offset + 2] << 16 | + source[offset + 3] << 24; + + private static uint ReadUInt32(ReadOnlySpan source, int offset) => + unchecked((uint)ReadInt32(source, offset)); + + internal readonly record struct WinPhoneHeader( + uint Version, + uint Crc, + string Title, + int BodyOffset, + int WorldId, + int RightWorld, + short BottomWorld, + short TilesHigh, + short TilesWide, + short DungeonX, + short DungeonY, + short SpawnX, + short SpawnY, + short GroundLevel, + short RockLevel, + byte CloudFlag = 0, + uint[]? History = null); + + internal enum WinPhonePrimitiveKind : byte + { + Byte, + Int16, + Int32, + } + + internal readonly record struct WinPhonePrimitive(WinPhonePrimitiveKind Kind, int Value); +} diff --git a/src/TEdit.Terraria/World.Properties.cs b/src/TEdit.Terraria/World.Properties.cs index 437fd0208..e50f8542f 100644 --- a/src/TEdit.Terraria/World.Properties.cs +++ b/src/TEdit.Terraria/World.Properties.cs @@ -153,8 +153,29 @@ private void UpdateMaxLayerLevels() [property: Category("World")] [Reactive] private bool _isChinese; - [property: Category("World")] - [Reactive] private bool _isConsole; + [property: Category("World")] + [Reactive] private bool _isConsole; + + [property: Category("World")] + [Reactive] private bool _isWinPhone; + + internal byte[]? WinPhoneSourceData { get; set; } + internal Chest?[]? WinPhoneChestSlots { get; set; } + internal Sign?[]? WinPhoneSignSlots { get; set; } + internal byte[]? WinPhoneSignTextFlags { get; set; } + internal int WinPhoneDataSectionOffset { get; set; } + internal int WinPhoneParsedLength { get; set; } + internal int WinPhoneTileSectionOffset { get; set; } + internal ushort[]? WinPhoneStoredTileTypes { get; set; } + internal byte[]? WinPhoneTileHeader1 { get; set; } + internal byte[]? WinPhoneTileHeader2 { get; set; } + internal byte[]? WinPhoneTileShape { get; set; } + internal byte[]? WinPhoneLegacyTileFlags { get; set; } + internal ushort[]? WinPhoneTileRunLengths { get; set; } + internal uint WinPhoneStoredCrc { get; set; } + internal byte WinPhoneCloudFlag { get; set; } + internal uint[]? WinPhoneHistory { get; set; } + internal WinPhonePrimitive[]? WinPhoneMetadataPrimitives { get; set; } #endregion diff --git a/src/TEdit.Terraria/World.cs b/src/TEdit.Terraria/World.cs index 6149dfe4c..cd9bf9068 100644 --- a/src/TEdit.Terraria/World.cs +++ b/src/TEdit.Terraria/World.cs @@ -99,9 +99,13 @@ await Task.Run(() => string temp = filename + ".tmp"; using (var fs = new FileStream(temp, FileMode.Create)) { - using (var bw = new BinaryWriter(fs)) - { - if (versionOverride < 0 || world.IsV0 || world.Version == 38) + using (var bw = new BinaryWriter(fs)) + { + if (world.IsWinPhone && versionOverride == 0) + { + SaveWinPhoneVerifiedUnchanged(world, fs); + } + else if (versionOverride < 0 || world.IsV0 || world.Version == 38) { bool addLight = (currentWorldVersion > world.Version); // Check if world is being downgraded. world.Version = (uint)Math.Abs(versionOverride); @@ -203,9 +207,13 @@ public static void Save( string temp = filename + ".tmp"; using (var fs = new FileStream(temp, FileMode.Create)) { - using (var bw = new BinaryWriter(fs)) - { - if (versionOverride < 0 || world.IsV0 || world.Version == 38) + using (var bw = new BinaryWriter(fs)) + { + if (world.IsWinPhone && versionOverride == 0) + { + SaveWinPhoneVerifiedUnchanged(world, fs); + } + else if (versionOverride < 0 || world.IsV0 || world.Version == 38) { bool addLight = (currentWorldVersion > world.Version) ? true : false; // Check if world is being downgraded. world.Version = (uint)Math.Abs(versionOverride); @@ -368,12 +376,19 @@ public static WorldValidationStatus ValidateWorldFile(string filename, IProgress } // reset the stream - b.BaseStream.Position = (long)0; - - progress?.Report(new ProgressChangedEventArgs(0, "Loading File Header...")); - // read section pointers and tile frame data - if (!LoadSectionHeader(b, out _, out _, w)) - throw new TEditFileFormatException("Invalid File Format Section"); + b.BaseStream.Position = (long)0; + + progress?.Report(new ProgressChangedEventArgs(0, "Loading File Header...")); + if (TryLoadWinPhone(b, w, headersOnly: true, progress)) + { + status.LoaderVersion = 3; + } + else + { + // read section pointers and tile frame data + if (!LoadSectionHeader(b, out _, out _, w)) + throw new TEditFileFormatException("Invalid File Format Section"); + } if (w.IsChinese) { @@ -602,9 +617,14 @@ public static (World World, Exception Error) LoadWorld(string filename, bool hea w.IsTModLoader = File.Exists(twldPath); - w.Version = b.ReadUInt32(); - - // only perform this check for v38 and under + w.Version = b.ReadUInt32(); + + bool loadedWinPhone = TryLoadWinPhone(b, w, headersOnly, progress); + + if (!loadedWinPhone) + { + + // only perform this check for v38 and under if (w.Version <= 38) { // save the stream position @@ -628,10 +648,11 @@ public static (World World, Exception Error) LoadWorld(string filename, bool hea { LoadV0(b, filename, w, headersOnly, progress); } - else - { - LoadV1(b, filename, w, headersOnly, progress); - } + else + { + LoadV1(b, filename, w, headersOnly, progress); + } + } } } w.LastSave = File.GetLastWriteTimeUtc(filename); diff --git a/src/TEdit.Tests/Terraria/WinPhoneWorldTests.cs b/src/TEdit.Tests/Terraria/WinPhoneWorldTests.cs new file mode 100644 index 000000000..06bffc2f4 --- /dev/null +++ b/src/TEdit.Tests/Terraria/WinPhoneWorldTests.cs @@ -0,0 +1,535 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace TEdit.Terraria.Tests; + +public class WinPhoneWorldTests +{ + private const string V60FixturePath = ".\\WorldFiles\\win-phone.world"; + + private static readonly string[] V49FixtureCandidates = + [ + Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, + "..", "..", "..", "..", "..", "..", + "_reference_sources", "winphone", "Terraria v1.0.0.0", "Tutorial.world")), + Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, + "..", "..", "..", "..", "..", "..", + "_reference_sources", "winphone", "Terraria v1.2.4.3", "Tutorial.world")), + Path.GetFullPath(Path.Combine( + Environment.CurrentDirectory, + "..", "_reference_sources", "winphone", "Terraria v1.2.4.3", "Tutorial.world")), + ]; + + [Fact] + public void LoadWorld_WinPhoneV60_ReadsNativeHeader() + { + Assert.True(File.Exists(V60FixturePath), $"Missing fixture: {V60FixturePath}"); + + var (world, error) = World.LoadWorld(V60FixturePath); + + Assert.Null(error); + Assert.True(world.IsWinPhone); + Assert.Equal((uint)60, world.Version); + Assert.Equal("ZSRR", world.Title); + Assert.Equal(1750, world.TilesWide); + Assert.Equal(1000, world.TilesHigh); + Assert.Equal(250, world.SpawnX); + Assert.Equal(267, world.SpawnY); + Assert.Equal(876, world.DungeonX); + Assert.Equal(298, world.DungeonY); + Assert.Equal(1_750_000, world.Tiles.Length); + Assert.True(ContainsActiveTile(world)); + } + + [Fact] + public void LoadSave_WinPhoneV60_IsByteIdentical() + { + AssertLosslessRoundTrip(V60FixturePath, expectedVersion: 60, expectedTitle: "ZSRR", fullLoad: true); + } + + [Fact] + public void LoadSave_WinPhoneV49Tutorial_IsByteIdentical() + { + string[] fixtures = Array.FindAll(V49FixtureCandidates, File.Exists); + Assert.Equal(2, fixtures.Length); + foreach (string fixture in fixtures) + AssertLosslessRoundTrip(fixture, expectedVersion: 49, expectedTitle: "Tutorial", fullLoad: true); + } + + [Fact] + public void ValidateWorldFile_WinPhoneV60_IsValidNativeContainer() + { + var status = World.ValidateWorldFile(V60FixturePath); + + Assert.Equal((uint)60, status.Version); + Assert.True(status.IsLegacy); + Assert.True(status.IsValid, status.Message); + Assert.False(status.IsConsole); + Assert.False(status.IsChinese); + Assert.Equal(3, status.LoaderVersion); + } + + [Fact] + public void Save_WinPhoneEditedTile_ReloadsChange() + { + string[] fixtures = [V60FixturePath, Array.Find(V49FixtureCandidates, File.Exists)!]; + Assert.DoesNotContain(fixtures, string.IsNullOrEmpty); + foreach (string fixture in fixtures) + { + var (world, error) = World.LoadWorld(fixture); + Assert.Null(error); + + bool expectedWire = !world.Tiles[0, 0].WireRed; + world.Tiles[0, 0].WireRed = expectedWire; + string savedPath = Path.Combine(Path.GetTempPath(), $"tedit-winphone-edited-{Guid.NewGuid():N}.world"); + try + { + World.Save(world, savedPath); + var (reloaded, reloadError) = World.LoadWorld(savedPath); + + Assert.Null(reloadError); + Assert.Equal(expectedWire, reloaded.Tiles[0, 0].WireRed); + Assert.True(World.ValidateWorldFile(savedPath).IsValid); + } + finally + { + File.Delete(savedPath); + File.Delete(savedPath + ".tmp"); + } + } + } + + [Fact] + public void Save_WinPhoneEditedSections_ReloadChanges() + { + var (world, error) = World.LoadWorld(V60FixturePath); + Assert.Null(error); + Assert.NotEmpty(world.Chests); + Assert.NotEmpty(world.Signs); + Assert.NotEmpty(world.NPCs); + Assert.NotEmpty(world.CharacterNames); + + Chest chest = world.Chests[0]; + int itemIndex = 0; + while (itemIndex < chest.MaxItems && chest.Items[itemIndex].NetId == 0) + itemIndex++; + Assert.True(itemIndex < chest.MaxItems); + + int expectedStack = chest.Items[itemIndex].StackSize + 1; + string expectedSign = world.Signs[0].Text + " edited"; + bool expectedHomeless = !world.NPCs[0].IsHomeless; + string expectedName = world.CharacterNames[0].Name + " Jr."; + chest.Items[itemIndex].StackSize = expectedStack; + world.Signs[0].Text = expectedSign; + world.NPCs[0].IsHomeless = expectedHomeless; + world.CharacterNames[0].Name = expectedName; + + string savedPath = Path.Combine(Path.GetTempPath(), $"tedit-winphone-sign-{Guid.NewGuid():N}.world"); + try + { + World.Save(world, savedPath); + var (reloaded, reloadError) = World.LoadWorld(savedPath); + + Assert.Null(reloadError); + Assert.Equal(expectedStack, reloaded.Chests[0].Items[itemIndex].StackSize); + Assert.Equal(expectedSign, reloaded.Signs[0].Text); + Assert.Equal(expectedHomeless, reloaded.NPCs[0].IsHomeless); + Assert.Equal(expectedName, reloaded.CharacterNames[0].Name); + } + finally + { + File.Delete(savedPath); + File.Delete(savedPath + ".tmp"); + } + } + + [Fact] + public void Save_WinPhoneEditableMetadata_ReloadsChanges() + { + string[] fixtures = [V60FixturePath, Array.Find(V49FixtureCandidates, File.Exists)!]; + Assert.DoesNotContain(fixtures, string.IsNullOrEmpty); + + foreach (string fixture in fixtures) + { + var (world, error) = World.LoadWorld(fixture); + Assert.Null(error); + + string expectedTitle = world.Title + " Edit"; + int expectedWorldId = world.WorldId + 1; + int expectedDungeonX = world.DungeonX - 1; + int expectedDungeonY = world.DungeonY - 1; + int expectedSpawnX = world.SpawnX + 1; + int expectedSpawnY = world.SpawnY + 1; + double expectedGround = world.GroundLevel + 1; + double expectedRock = world.RockLevel + 1; + double expectedTime = world.Time + 1; + bool expectedDayTime = !world.DayTime; + int expectedMoonPhase = (world.MoonPhase + 1) % 8; + bool expectedBloodMoon = !world.BloodMoon; + bool expectedCrimson = !world.IsCrimson; + bool expectedRaining = !world.IsRaining; + int expectedRainTime = world.TempRainTime + 60; + float expectedMaxRain = world.TempMaxRain == 0.5f ? 0.25f : 0.5f; + + world.Title = expectedTitle; + world.WorldId = expectedWorldId; + world.DungeonX = expectedDungeonX; + world.DungeonY = expectedDungeonY; + world.SpawnX = expectedSpawnX; + world.SpawnY = expectedSpawnY; + world.GroundLevel = expectedGround; + world.RockLevel = expectedRock; + world.Time = expectedTime; + world.DayTime = expectedDayTime; + world.MoonPhase = expectedMoonPhase; + world.BloodMoon = expectedBloodMoon; + if (world.Version >= 58) + { + world.IsCrimson = expectedCrimson; + world.IsRaining = expectedRaining; + world.TempRainTime = expectedRainTime; + world.TempMaxRain = expectedMaxRain; + } + + string savedPath = Path.Combine( + Path.GetTempPath(), $"tedit-winphone-metadata-{Guid.NewGuid():N}.world"); + try + { + World.Save(world, savedPath); + var (reloaded, reloadError) = World.LoadWorld(savedPath); + + Assert.Null(reloadError); + Assert.True(reloaded.IsWinPhone); + Assert.Equal(expectedTitle, reloaded.Title); + Assert.Equal(expectedWorldId, reloaded.WorldId); + Assert.Equal(expectedDungeonX, reloaded.DungeonX); + Assert.Equal(expectedDungeonY, reloaded.DungeonY); + Assert.Equal(expectedSpawnX, reloaded.SpawnX); + Assert.Equal(expectedSpawnY, reloaded.SpawnY); + Assert.Equal(expectedGround, reloaded.GroundLevel); + Assert.Equal(expectedRock, reloaded.RockLevel); + Assert.Equal(expectedTime, reloaded.Time); + Assert.Equal(expectedDayTime, reloaded.DayTime); + Assert.Equal(expectedMoonPhase, reloaded.MoonPhase); + Assert.Equal(expectedBloodMoon, reloaded.BloodMoon); + if (world.Version >= 58) + { + Assert.Equal(expectedCrimson, reloaded.IsCrimson); + Assert.Equal(expectedRaining, reloaded.IsRaining); + Assert.Equal(expectedRainTime, reloaded.TempRainTime); + Assert.Equal(expectedMaxRain, reloaded.TempMaxRain); + } + Assert.True(World.ValidateWorldFile(savedPath).IsValid); + } + finally + { + File.Delete(savedPath); + File.Delete(savedPath + ".tmp"); + } + } + } + + [Fact] + public void Save_WinPhoneProgressionAndWorldState_ReloadsChanges() + { + string[] fixtures = [V60FixturePath, Array.Find(V49FixtureCandidates, File.Exists)!]; + Assert.DoesNotContain(fixtures, string.IsNullOrEmpty); + + foreach (string fixture in fixtures) + { + var (world, error) = World.LoadWorld(fixture); + Assert.Null(error); + + world.DownedBoss1EyeofCthulhu = !world.DownedBoss1EyeofCthulhu; + world.DownedBoss2EaterofWorlds = !world.DownedBoss2EaterofWorlds; + world.DownedBoss3Skeletron = !world.DownedBoss3Skeletron; + world.SavedGoblin = !world.SavedGoblin; + world.SavedWizard = !world.SavedWizard; + world.SavedMech = !world.SavedMech; + world.DownedGoblins = !world.DownedGoblins; + world.DownedClown = !world.DownedClown; + world.DownedFrost = !world.DownedFrost; + world.ShadowOrbCount = world.ShadowOrbCount == 2 ? 1 : 2; + world.ShadowOrbSmashed = true; + world.SpawnMeteor = !world.SpawnMeteor; + world.AltarCount += 1; + world.HardMode = !world.HardMode; + world.InvasionDelay = world.InvasionDelay == 6 ? 5 : 6; + world.InvasionSize = world.InvasionSize == 123 ? 122 : 123; + world.InvasionType = world.InvasionType == 3 ? 1 : 3; + world.InvasionX = 321; + + if (world.Version >= 58) + { + world.DownedQueenBee = !world.DownedQueenBee; + world.DownedMechBoss1TheDestroyer = !world.DownedMechBoss1TheDestroyer; + world.DownedMechBoss2TheTwins = !world.DownedMechBoss2TheTwins; + world.DownedMechBoss3SkeletronPrime = !world.DownedMechBoss3SkeletronPrime; + world.DownedPlantBoss = !world.DownedPlantBoss; + world.DownedGolemBoss = !world.DownedGolemBoss; + world.DownedPirates = !world.DownedPirates; + world.SavedOreTiersCobalt = world.SavedOreTiersCobalt == 107 ? 221 : 107; + world.SavedOreTiersMythril = world.SavedOreTiersMythril == 108 ? 222 : 108; + world.SavedOreTiersAdamantite = world.SavedOreTiersAdamantite == 111 ? 223 : 111; + } + + string savedPath = Path.Combine( + Path.GetTempPath(), $"tedit-winphone-state-{Guid.NewGuid():N}.world"); + try + { + World.Save(world, savedPath); + var (reloaded, reloadError) = World.LoadWorld(savedPath); + + Assert.Null(reloadError); + Assert.Equal(world.DownedBoss1EyeofCthulhu, reloaded.DownedBoss1EyeofCthulhu); + Assert.Equal(world.DownedBoss2EaterofWorlds, reloaded.DownedBoss2EaterofWorlds); + Assert.Equal(world.DownedBoss3Skeletron, reloaded.DownedBoss3Skeletron); + Assert.Equal(world.SavedGoblin, reloaded.SavedGoblin); + Assert.Equal(world.SavedWizard, reloaded.SavedWizard); + Assert.Equal(world.SavedMech, reloaded.SavedMech); + Assert.Equal(world.DownedGoblins, reloaded.DownedGoblins); + Assert.Equal(world.DownedClown, reloaded.DownedClown); + Assert.Equal(world.DownedFrost, reloaded.DownedFrost); + Assert.True(reloaded.ShadowOrbSmashed); + Assert.Equal(world.ShadowOrbCount, reloaded.ShadowOrbCount); + Assert.Equal(world.SpawnMeteor, reloaded.SpawnMeteor); + Assert.Equal(world.AltarCount, reloaded.AltarCount); + Assert.Equal(world.HardMode, reloaded.HardMode); + Assert.Equal(world.InvasionDelay, reloaded.InvasionDelay); + Assert.Equal(world.InvasionSize, reloaded.InvasionSize); + Assert.Equal(world.InvasionType, reloaded.InvasionType); + Assert.Equal(world.InvasionX, reloaded.InvasionX); + + if (world.Version >= 58) + { + Assert.Equal(world.DownedQueenBee, reloaded.DownedQueenBee); + Assert.Equal(world.DownedMechBoss1TheDestroyer, reloaded.DownedMechBoss1TheDestroyer); + Assert.Equal(world.DownedMechBoss2TheTwins, reloaded.DownedMechBoss2TheTwins); + Assert.Equal(world.DownedMechBoss3SkeletronPrime, reloaded.DownedMechBoss3SkeletronPrime); + Assert.Equal(world.DownedPlantBoss, reloaded.DownedPlantBoss); + Assert.Equal(world.DownedGolemBoss, reloaded.DownedGolemBoss); + Assert.Equal(world.DownedPirates, reloaded.DownedPirates); + Assert.Equal(world.SavedOreTiersCobalt, reloaded.SavedOreTiersCobalt); + Assert.Equal(world.SavedOreTiersMythril, reloaded.SavedOreTiersMythril); + Assert.Equal(world.SavedOreTiersAdamantite, reloaded.SavedOreTiersAdamantite); + } + Assert.True(World.ValidateWorldFile(savedPath).IsValid); + } + finally + { + File.Delete(savedPath); + File.Delete(savedPath + ".tmp"); + } + } + } + + [Fact] + public void Save_WinPhoneSparseSections_AddAndRemoveEntries() + { + string[] fixtures = [V60FixturePath, Array.Find(V49FixtureCandidates, File.Exists)!]; + Assert.DoesNotContain(fixtures, string.IsNullOrEmpty); + + foreach (string fixture in fixtures) + { + var (world, error) = World.LoadWorld(fixture); + Assert.Null(error); + Assert.NotEmpty(world.Chests); + Assert.NotEmpty(world.NPCs); + + Chest removedChest = world.Chests[0]; + world.Chests.RemoveAt(0); + int addedChestX = 12; + int addedChestY = 34; + while ((removedChest.X == addedChestX && removedChest.Y == addedChestY) || + world.Chests.Any(chest => chest.X == addedChestX && chest.Y == addedChestY)) + addedChestX++; + Chest addedChest = new(addedChestX, addedChestY) { MaxItems = world.Version < 58 ? 20 : 40 }; + addedChest.Items[0] = new Item(2, 1, 0); + world.Chests.Add(addedChest); + + if (world.Signs.Count > 0) + world.Signs.RemoveAt(0); + world.Signs.Add(new Sign(14, 36, "Windows Phone sign")); + + world.NPCs.RemoveAt(0); + world.NPCs.Add(new NPC + { + SpriteId = 17, + Position = new Vector2FloatObservable(160, 320), + IsHomeless = false, + Home = new Vector2Int32Observable(10, 20), + }); + + string savedPath = Path.Combine( + Path.GetTempPath(), $"tedit-winphone-sections-{Guid.NewGuid():N}.world"); + try + { + World.Save(world, savedPath); + var (reloaded, reloadError) = World.LoadWorld(savedPath); + + Assert.Null(reloadError); + Assert.DoesNotContain(reloaded.Chests, chest => + chest.X == removedChest.X && chest.Y == removedChest.Y); + Chest reloadedChest = Assert.Single(reloaded.Chests, chest => + chest.X == addedChest.X && chest.Y == addedChest.Y); + Assert.Equal(2, reloadedChest.Items[0].StackSize); + Assert.Contains(reloaded.Signs, sign => + sign.X == 14 && sign.Y == 36 && sign.Text == "Windows Phone sign"); + Assert.Contains(reloaded.NPCs, npc => + npc.SpriteId == 17 && npc.Home.X == 10 && npc.Home.Y == 20); + } + finally + { + File.Delete(savedPath); + File.Delete(savedPath + ".tmp"); + } + } + } + + [Fact] + public async Task SaveAsWinPhone_DesktopWorld_AppliesNativeCompatibilityLimits() + { + World world = new(40, 60, new string('T', 200)) + { + Tiles = new Tile[60, 40], + }; + world.Tiles[1, 1] = new Tile + { + IsActive = true, + Type = 600, + Wall = 300, + LiquidAmount = 255, + LiquidType = LiquidType.Shimmer, + }; + Chest chest = new(2, 2) { MaxItems = 40 }; + chest.Items[0] = new Item(50_000, 1, 0); + world.Chests.Add(chest); + world.NPCs.Add(new NPC { SpriteId = 300 }); + + string savedPath = Path.Combine(Path.GetTempPath(), $"tedit-winphone-converted-{Guid.NewGuid():N}.world"); + try + { + await World.SaveWinPhoneAsync(world, savedPath); + var (reloaded, error) = World.LoadWorld(savedPath); + + Assert.Null(error); + Assert.True(world.IsWinPhone); + Assert.Equal((uint)60, world.Version); + Assert.True(reloaded.IsWinPhone); + Assert.Equal((uint)60, reloaded.Version); + Assert.Equal(128, reloaded.Title.Length); + Assert.False(reloaded.Tiles[1, 1].IsActive); + Assert.Equal(0, reloaded.Tiles[1, 1].Wall); + Assert.Equal(0, reloaded.Tiles[1, 1].LiquidAmount); + Assert.Equal(short.MaxValue, reloaded.Chests[0].Items[0].StackSize); + Assert.Empty(reloaded.NPCs); + Assert.True(World.ValidateWorldFile(savedPath).IsValid); + + World.Save(world, savedPath); + Assert.True(World.ValidateWorldFile(savedPath).IsValid); + } + finally + { + File.Delete(savedPath); + File.Delete(savedPath + ".tmp"); + } + } + + [Fact] + public void SaveAsWinPhone_ExpandedWorldMaximum_IsEnforced() + { + World tooWide = new(1, World.WinPhoneMaxWorldWidth + 1, "Too wide"); + using MemoryStream output = new(); + + NotSupportedException error = Assert.Throws(() => + World.SaveWinPhoneConverted(tooWide, output)); + + Assert.Contains("4200 × 1200", error.Message); + } + + [Fact] + public void Reconstruct_WinPhoneDecodedSections_DoesNotCopyCachedSectionBytes() + { + string[] fixtures = [V60FixturePath, .. Array.FindAll(V49FixtureCandidates, File.Exists)]; + Assert.Equal(3, fixtures.Length); + + foreach (string fixture in fixtures) + { + byte[] expected = File.ReadAllBytes(fixture); + var (world, error) = World.LoadWorld(fixture); + Assert.Null(error); + + Array.Fill( + world.WinPhoneSourceData!, + (byte)0xA5, + 0, + world.WinPhoneParsedLength); + + using MemoryStream output = new(expected.Length); + World.SaveWinPhoneReconstructed(world, output); + Assert.Equal(expected, output.ToArray()); + } + } + + private static void AssertLosslessRoundTrip( + string fixture, + uint expectedVersion, + string expectedTitle, + bool fullLoad) + { + byte[] expected = File.ReadAllBytes(fixture); + var (world, error) = World.LoadWorld(fixture, headersOnly: !fullLoad); + + Assert.Null(error); + Assert.True(world.IsWinPhone); + Assert.Equal(expectedVersion, world.Version); + Assert.Equal(expectedTitle, world.Title); + + if (fullLoad) + { + Assert.NotEmpty(world.Chests); + if (expectedVersion < 50) + Assert.Empty(world.Signs); + else + Assert.NotEmpty(world.Signs); + Assert.Equal(expectedVersion < 50 ? 2 : 1, world.NPCs.Count); + Assert.Equal(expectedVersion < 50 ? 10 : 18, world.CharacterNames.Count); + Assert.Equal(expectedVersion < 50 ? 35 : 79, world.WinPhoneMetadataPrimitives!.Length); + Assert.Equal(expectedVersion < 50 ? 201_842 : expected.Length, world.WinPhoneParsedLength); + } + + using var output = new MemoryStream(expected.Length); + World.SaveWinPhoneUnchanged(world, output); + + Assert.Equal(expected, output.ToArray()); + + if (fullLoad) + { + string savedPath = Path.Combine(Path.GetTempPath(), $"tedit-winphone-{Guid.NewGuid():N}.world"); + try + { + World.Save(world, savedPath); + Assert.Equal(expected, File.ReadAllBytes(savedPath)); + } + finally + { + File.Delete(savedPath); + File.Delete(savedPath + ".tmp"); + } + } + } + + private static bool ContainsActiveTile(World world) + { + for (int x = 0; x < world.TilesWide; x++) + for (int y = 0; y < world.TilesHigh; y++) + if (world.Tiles[x, y].IsActive) + return true; + return false; + } +} diff --git a/src/TEdit.Tests/WorldFiles/win-phone.world b/src/TEdit.Tests/WorldFiles/win-phone.world new file mode 100644 index 000000000..1aa48ae5d Binary files /dev/null and b/src/TEdit.Tests/WorldFiles/win-phone.world differ diff --git a/src/TEdit/View/Sidebar/WorldPropertiesView.xaml b/src/TEdit/View/Sidebar/WorldPropertiesView.xaml index 539f3c5a7..2efa22610 100644 --- a/src/TEdit/View/Sidebar/WorldPropertiesView.xaml +++ b/src/TEdit/View/Sidebar/WorldPropertiesView.xaml @@ -374,11 +374,11 @@ - + - - - + + + @@ -566,14 +566,14 @@ diff --git a/src/TEdit/ViewModel/WorldViewModel.cs b/src/TEdit/ViewModel/WorldViewModel.cs index f17694801..cfe413ef9 100644 --- a/src/TEdit/ViewModel/WorldViewModel.cs +++ b/src/TEdit/ViewModel/WorldViewModel.cs @@ -3999,11 +3999,13 @@ private async Task SaveWorldAsAsync() { // FilterIndex: // 1 = "Terraria World File" - // 2 = first game version entry - // 3 = second game version entry + // 2 = Windows Phone v1.2.4.3 + // 3 = first game version entry + // 4 = second game version entry // ... Filter = "Terraria World File|*.wld|" + + "Windows Phone v1.2.4.3 world|*.world|" + string.Join("|", versionKeys.Select(v => $"Terraria v{v}|*.wld")), Title = "Save World As", @@ -4015,12 +4017,18 @@ private async Task SaveWorldAsAsync() { CurrentFile = sfd.FileName; + if (sfd.FilterIndex == 2) + { + await SaveWorldFileAsync(saveAsWinPhone: true); + return; + } + // If they picked a specific Terraria version filter, use that gameVersion -> saveVersion mapping. - if (sfd.FilterIndex > 1) + if (sfd.FilterIndex > 2) { try { - int idx = sfd.FilterIndex - 2; // map FilterIndex to versionKeys index + int idx = sfd.FilterIndex - 3; // map FilterIndex to versionKeys index if (idx >= 0 && idx < versionKeys.Count) { @@ -4045,7 +4053,10 @@ private async Task SaveWorldAsAsync() } } - private async Task SaveWorldFileAsync(uint version = 0, bool preserveAll = true) + private async Task SaveWorldFileAsync( + uint version = 0, + bool preserveAll = true, + bool saveAsWinPhone = false) { if (CurrentWorld == null) return; @@ -4106,19 +4117,31 @@ private async Task SaveWorldFileAsync(uint version = 0, bool preserveAll = true) _loadedFromBackup = false; _originalBackupPath = null; - await SaveWorldThreadedAsync(CurrentFile, GetSaveVersion_MaxConfig(version), preserveAll); + await SaveWorldThreadedAsync( + CurrentFile, + GetSaveVersion_MaxConfig(version), + preserveAll, + saveAsWinPhone); } - private async Task SaveWorldThreadedAsync(string filename, uint version = 0, bool preserveAll = true) + private async Task SaveWorldThreadedAsync( + string filename, + uint version = 0, + bool preserveAll = true, + bool saveAsWinPhone = false) { bool saveCompleted = false; try { OnProgressChanged(CurrentWorld, new ProgressChangedEventArgs(0, "Saving World...")); - await World.SaveAsync(CurrentWorld, filename, versionOverride: (int)version, preserveAll: preserveAll, progress: new Progress(e => + Progress progress = new(e => { DispatcherHelper.CheckBeginInvokeOnUI(() => OnProgressChanged(CurrentWorld, e)); - })); + }); + if (saveAsWinPhone) + await World.SaveWinPhoneAsync(CurrentWorld, filename, progress); + else + await World.SaveAsync(CurrentWorld, filename, versionOverride: (int)version, preserveAll: preserveAll, progress: progress); saveCompleted = true; } catch (Exception ex)