diff --git a/docs/Contributing.md b/docs/Contributing.md
index 99f96dac7..7580b4412 100644
--- a/docs/Contributing.md
+++ b/docs/Contributing.md
@@ -36,6 +36,12 @@ WAE supports all target games through a single executable. All submitted code _m
There are 3 branches: `master` is for Dawn of the Tiberium Age, `tsclient` is for Tiberian Sun (CnCNet Client version), and `yr` is for Yuri's Revenge. Code-wise these branches are identical, but `tsclient` and `yr` have one additional commit that gives them different INI configurations compared to `master`.
+### Fail-Fast Design
+
+Do not write defensive code. Incorrect mod configurations or unexpected/potentially corrupted state should raise an exception with a clear message instead of being tolerated. This allows us to catch possible bugs and issues early. The only exception to this is config mistakes that are present in original Tiberian Sun or Red Alert 2 or their expansion packs - we have to tolerate those due to the nature of the application.
+
+This naturally does not apply to MCP server message parsing or other data received from an outside application. Such data should always be validated before it is acted upon. Potential validation failures should be logged and related operations skipped.
+
### Translations
If your code introduces new strings that are displayed in the user interface, the strings _must_ go through the fitting `Translate` function to enable multi-language support. The codebase has a lot of examples on how to do this. Also, you need to add the new strings in a fitting location in the `Translation_en.ini` reference translation file.
@@ -82,6 +88,11 @@ if (SomeReallyLongCondition() ||
DoSomething();
}
+// Not OK
+if (SomeReallyLongCondition() ||
+ ThatSplitsIntoMultipleLines())
+ DoSomething();
+
// OK
if (SomeCondition())
{
@@ -142,6 +153,21 @@ if (SomeCondition())
return;
}
```
+
+- Do not separate function call parameters on their own lines unless the code for the call would become prohibitely wide (over 160 characters). If the line becomes too wide, prefer splitting it at the parameter where it would become too wide, instead of at each parameter.
+
+```
+// OK
+mcpServer = new MCPServer(WindowManager, new MapFacade(map, mutationManager, mapUI.MutationTarget), new ScriptingFacade(map), mapUI);
+
+// Not OK
+mcpServer = new MCPServer(
+ WindowManager,
+ new MapFacade(map, mutationManager, mapUI.MutationTarget),
+ new ScriptingFacade(map),
+ mapUI);
+```
+
- Use `var` with local variables when the type of the variable is obvious from the code or the type is not relevant. Never use `var` with primitive types.
```cs
// OK
diff --git a/docs/MCP-Scripting-API.md b/docs/MCP-Scripting-API.md
new file mode 100644
index 000000000..d18b5047e
--- /dev/null
+++ b/docs/MCP-Scripting-API.md
@@ -0,0 +1,328 @@
+# MCP Scripting API
+
+## Purpose
+
+The World-Altering Editor MCP server exposes map-local TaskForces, Scripts, TeamTypes, AITriggers, Local Variables, Triggers, and Tags to AI agents. The API supports browsing the active game configuration and applying related scripting changes as one validated, atomic operation.
+
+Scripting changes are applied atomically and are not added to map undo/redo history. Saving the map persists the result, but normal Undo cannot revert it; an agent must read the new state and submit an explicit inverse change if one is needed.
+
+## Recommended workflow
+
+1. Discover active definitions, HouseTypes, object INI names, waypoints, flags, and other parameter options.
+2. Browse any existing elements that will be updated, deleted, referenced, or guarded.
+3. Build one change set, using `TemporaryKey` references between elements created together.
+4. Call `validate_scripting_changes` for a dry run.
+5. If validation succeeds, call `apply_scripting_changes` with the same logical request and still-current hashes.
+6. Keep the generated IDs and `ContentHash` values returned in `created` and `updated`.
+
+Validation and application both run against the current map state. A successful validation is therefore not a lock: application can still reject the request if a guarded element changes in between.
+
+## Runtime discovery
+
+Do not assume that a familiar action, event, HouseType, unit, flag, side, or preset has its default ID or INI name. Discover these values from the active game configuration.
+
+- `get_scripting_definitions` returns Script actions, Trigger events and actions, their editable and hardcoded parameter definitions, preset options, TeamType flags and defaults, AITrigger conditions and comparators, and sides.
+- `get_house_types` returns valid owner HouseType INI names.
+- `get_scripting_parameter_options` returns current semantic options for types such as `Techno`, `TeamType`, `HouseType`, `Waypoint`, `WaypointZZ`, `LocalVariable`, `Sound`, and `Theme`.
+- The element browsing tools return permanent IDs, references, raw values where relevant, and current content hashes. `includeGlobal` exposes read-only global TaskForces, Scripts, and TeamTypes where supported.
+
+The numeric definition IDs used in the examples below are the bundled defaults only. Always look up the matching definition by its runtime name and substitute the returned ID. Likewise, `Soviet`, `HTNK`, and `V2RL` are illustrative INI names that must be replaced with values returned for the loaded rules.
+
+## Content hashes and concurrent editing
+
+Every returned permanent scripting element includes a deterministic `ContentHash` in the form `sha256:...`. It is calculated on demand from the element's canonical persisted state and is independent of the global map revision.
+
+Every update and delete must include the element's current `ExpectedContentHash`. Immediately before applying a change set, the server recalculates all expected hashes and rejects the entire request if one differs. This prevents an agent from overwriting a human's concurrent edit to the same scripting element while allowing unrelated terrain, object, and scripting work to continue.
+
+`Preconditions` add the same guard to elements that influenced the requested change but are not themselves updated or deleted. They can use permanent identities returned by reference inspection, including global scripting elements and read-only `MapTechno` and `CellTag` reference sources. A precondition cannot use a `TemporaryKey`, because temporary elements do not exist before the change set.
+
+For example, an update is a complete replacement, not a patch:
+
+```json
+{
+ "request": {
+ "updates": {
+ "taskForces": [
+ {
+ "id": "01000000",
+ "expectedContentHash": "sha256:current-task-force-hash",
+ "replacement": {
+ "name": "Updated Soviet armor",
+ "group": -1,
+ "members": [
+ { "index": 0, "technoType": "HTNK", "count": 6 },
+ { "index": 3, "technoType": "V2RL", "count": 4 }
+ ]
+ }
+ }
+ ]
+ },
+ "preconditions": [
+ {
+ "kind": "TeamType",
+ "id": "01000002",
+ "expectedContentHash": "sha256:current-team-type-hash"
+ }
+ ]
+ }
+}
+```
+
+Copy every editable property from the latest browse result into `replacement`, changing only what the user requested. Omitted optional properties are cleared or reset to their documented defaults. Updates and deletes apply only to map-local editable elements; global elements and read-only reference sources may only be browsed, referenced, or guarded.
+
+## Temporary references
+
+The server generates permanent INI IDs, so a caller cannot know the IDs of several related elements before creating them in one operation. A create can therefore have a request-scoped `TemporaryKey`, and another value in the same change set can reference it with `TemporaryKey` plus the expected element `Kind`.
+
+A reference must specify exactly one of `Id`, `Index`, or `TemporaryKey`. Temporary keys are case-sensitive, may not have leading or trailing whitespace, are never stored in the map, and are distinct from permanent IDs and user-facing names. The result maps each key to its generated permanent ID or Local Variable index.
+
+## Atomic change sets
+
+`apply_scripting_changes` accepts typed create, update, delete, and precondition collections. The server performs these phases atomically:
+
+1. Validate request shapes, temporary keys, runtime definitions, references, and primitive values.
+2. Verify expected content hashes and preconditions.
+3. Reserve permanent scripting IDs and Local Variable indices.
+4. Construct new elements and resolve permanent and temporary references.
+5. Validate the projected reference graph and reject dangling references or unsafe deletions.
+6. Commit the validated changes and return normalized results and generated IDs.
+
+Validation errors commit nothing, and unexpected commit-time failures are rolled back. Deletion never silently cascades or detaches references.
+
+## Parameter handling
+
+Script actions, Trigger events, and Trigger actions are identified by IDs returned from `get_scripting_definitions`. Callers provide semantic values rather than engine storage encodings:
+
+- A configured preset accepts its returned `Value`, full option text, or display label. For example, the bundled `Do This` action accepts either `14` or `Hunt`.
+- Object-valued parameters accept the option's INI name or index as described by its parameter type.
+- `HouseType` accepts a loaded INI name or a raw numeric value documented by the active definition, including sentinel values such as `-1` for any house.
+- `Waypoint` and `WaypointZZ` both accept a numeric waypoint identifier, which must exist in the current map.
+- Reference-valued parameters use a typed reference object and may point to an existing permanent element or a `TemporaryKey` in the same request.
+- Trigger event/action parameter entries include the zero-based configured parameter `Index`.
+
+Supply only editable parameters. Omit parameters whose definition reports `IsUsed: false` or `IsHardcoded: true`; the server fills those fields with their configured defaults. Unknown existing Script or Trigger action definitions remain browseable through raw values and may be preserved unchanged, but a newly authored action requires an active runtime definition.
+
+TaskForce members similarly have an optional zero-based `Index` from 0 through 5. Omit it to use the first free slot. Preserve the indices returned by `get_task_forces` during a full-replacement update so sparse INI slots are not compacted unintentionally.
+
+## Discovery and browsing tools
+
+- `get_house_types`
+- `get_scripting_definitions`
+- `get_scripting_parameter_options`
+- `get_task_forces`
+- `get_scripts`
+- `get_team_types`
+- `get_ai_triggers`
+- `get_local_variables`
+- `get_triggers`
+- `get_tags`
+- `get_scripting_references`
+- `validate_scripting_changes`
+- `apply_scripting_changes`
+
+## End-to-end example: Hard-only Soviet AI attack
+
+Goal: produce five Heavy Tanks and four V2 Launchers for the Soviet HouseType on Hard difficulty, then run `Do This -> Hunt`.
+
+First discover the active values:
+
+```text
+get_house_types { "nameFilter": "Soviet" }
+get_scripting_definitions {}
+get_scripting_parameter_options { "parameterType": "Techno", "nameFilter": "Heavy Tank" }
+get_scripting_parameter_options { "parameterType": "Techno", "nameFilter": "V2 Launcher" }
+```
+
+From those results, select the exact HouseType and object INI names, the `Do This` action ID and `Hunt` preset, the unconditional AITrigger condition, comparator, and side. With the bundled illustrative values, one atomic `apply_scripting_changes` request is:
+
+```json
+{
+ "request": {
+ "creates": {
+ "taskForces": [
+ {
+ "temporaryKey": "hard-soviet-force",
+ "value": {
+ "name": "H Soviet Armor",
+ "group": -1,
+ "members": [
+ { "index": 0, "technoType": "HTNK", "count": 5 },
+ { "index": 1, "technoType": "V2RL", "count": 4 }
+ ]
+ }
+ }
+ ],
+ "scripts": [
+ {
+ "temporaryKey": "hard-soviet-hunt-script",
+ "value": {
+ "name": "Do This - Hunt",
+ "actions": [
+ { "actionId": 11, "value": "Hunt" }
+ ]
+ }
+ }
+ ],
+ "teamTypes": [
+ {
+ "temporaryKey": "hard-soviet-team",
+ "value": {
+ "name": "H Soviet Assault Team",
+ "group": -1,
+ "houseType": "Soviet",
+ "script": { "kind": "Script", "temporaryKey": "hard-soviet-hunt-script" },
+ "taskForce": { "kind": "TaskForce", "temporaryKey": "hard-soviet-force" },
+ "max": 1,
+ "priority": 7,
+ "techLevel": 0,
+ "veteranLevel": 1
+ }
+ }
+ ],
+ "aiTriggers": [
+ {
+ "temporaryKey": "hard-soviet-ai-trigger",
+ "value": {
+ "name": "H Soviet Armor Attack",
+ "primaryTeam": { "kind": "TeamType", "temporaryKey": "hard-soviet-team" },
+ "ownerName": "Soviet",
+ "techLevel": 0,
+ "conditionType": -1,
+ "comparatorOperator": 0,
+ "comparatorQuantity": 0,
+ "initialWeight": 50,
+ "minimumWeight": 30,
+ "maximumWeight": 70,
+ "side": 0,
+ "easy": false,
+ "medium": false,
+ "hard": true,
+ "enabled": true
+ }
+ }
+ ]
+ }
+ }
+}
+```
+
+Omitting `enabledFlags` on a new TeamType applies the active configuration defaults. The TeamType has `max: 1` because an AITrigger cannot produce a map-local team whose maximum is zero.
+
+## End-to-end example: timed off-map reinforcement
+
+Goal: after 300 in-game seconds, create eight Heavy Tanks at waypoint 44 and have them hunt the player.
+
+Discover the same HouseType, Heavy Tank, `Do This -> Hunt` values, plus the runtime definitions named `Elapsed Time` and `Reinforcement At Waypoint`. Confirm waypoint 44 exists:
+
+```text
+get_scripting_definitions {}
+get_scripting_parameter_options { "parameterType": "Waypoint", "nameFilter": "44" }
+```
+
+Using bundled default definition IDs 11, 13, and 80 for illustration:
+
+```json
+{
+ "request": {
+ "creates": {
+ "taskForces": [
+ {
+ "temporaryKey": "reinforcement-force",
+ "value": {
+ "name": "Eight heavy tanks",
+ "group": -1,
+ "members": [
+ { "index": 0, "technoType": "HTNK", "count": 8 }
+ ]
+ }
+ }
+ ],
+ "scripts": [
+ {
+ "temporaryKey": "reinforcement-hunt-script",
+ "value": {
+ "name": "Reinforcement hunt",
+ "actions": [
+ { "actionId": 11, "value": "Hunt" }
+ ]
+ }
+ }
+ ],
+ "teamTypes": [
+ {
+ "temporaryKey": "reinforcement-team",
+ "value": {
+ "name": "Heavy tank reinforcement team",
+ "group": -1,
+ "houseType": "Soviet",
+ "script": { "kind": "Script", "temporaryKey": "reinforcement-hunt-script" },
+ "taskForce": { "kind": "TaskForce", "temporaryKey": "reinforcement-force" },
+ "max": 0,
+ "priority": 7,
+ "waypoint": 44,
+ "techLevel": 0,
+ "veteranLevel": 1
+ }
+ }
+ ],
+ "triggers": [
+ {
+ "temporaryKey": "reinforcement-trigger",
+ "value": {
+ "name": "Heavy tanks after 300 seconds",
+ "houseType": "Soviet",
+ "disabled": false,
+ "easy": true,
+ "normal": true,
+ "hard": true,
+ "events": [
+ {
+ "eventId": 13,
+ "parameters": [
+ { "index": 1, "value": "300" }
+ ]
+ }
+ ],
+ "actions": [
+ {
+ "actionId": 80,
+ "parameters": [
+ {
+ "index": 1,
+ "reference": { "kind": "TeamType", "temporaryKey": "reinforcement-team" }
+ },
+ { "index": 6, "value": "44" }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "tags": [
+ {
+ "temporaryKey": "reinforcement-tag",
+ "value": {
+ "name": "Heavy tank reinforcement tag",
+ "repeating": 0,
+ "trigger": { "kind": "Trigger", "temporaryKey": "reinforcement-trigger" }
+ }
+ }
+ ]
+ }
+ }
+}
+```
+
+Only the editable event/action parameters are present. For the bundled definitions, Elapsed Time uses parameter index 1; Reinforcement At Waypoint uses the TeamType at index 1 and waypoint at index 6. The server fills action parameter 0 with its hardcoded value and normalizes all unused storage fields.
+
+## Validation principles
+
+- TaskForces contain one through six positive-count member entries drawn from valid aircraft, infantry, or vehicle types.
+- Scripts use active configured actions and respect the engine's supported action count.
+- TeamTypes reference valid TaskForces, Scripts, optional Tags, HouseTypes, flags, and existing waypoints.
+- AITriggers reference valid TeamTypes and game-appropriate condition types, owners, sides, and comparison objects. AI-produced map-local teams must have `Max` of at least 1.
+- Local Variable indices are unique; an omitted create index allocates the first available value.
+- Trigger definitions, parameter types, availability, owners, linked triggers, difficulty flags, and special encodings are validated. A Trigger may contain at most 18 actions.
+- Tags reference a valid Trigger and use a repeating value from 0 through 2.
+- Deletion checks incoming references from scripting elements, placed technos, and cell tags.
+- Global TaskForces, Scripts, and TeamTypes may be browsed, referenced, and guarded where supported, but cannot be edited through map-local operations.
diff --git a/src/MapEditorLibrary/CCEngine/AutoLATType.cs b/src/MapEditorLibrary/CCEngine/AutoLATType.cs
new file mode 100644
index 000000000..c573e8e52
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/AutoLATType.cs
@@ -0,0 +1,153 @@
+namespace MapEditorLibrary.CCEngine;
+
+public struct AutoLATData
+{
+ public int TransitionTypeIndex;
+ public int[] TransitionMatchArray;
+
+ public AutoLATData(int transitionTypeTileSetIndex, int[] transitionMatchArray)
+ {
+ TransitionTypeIndex = transitionTypeTileSetIndex;
+ TransitionMatchArray = transitionMatchArray;
+ }
+}
+
+public static class AutoLATType
+{
+ // Transition types and related tileset indexes
+ public const int SURROUNDS_TILE = 0;
+ public const int NE_TRANSITION = 1;
+ public const int SE_TRANSITION = 2;
+ public const int NE_SE_TRANSITION = 3;
+ public const int SW_TRANSITION = 4;
+ public const int NE_SW_TRANSITION = 5;
+ public const int SE_SW_TRANSITION = 6;
+ public const int NE_SE_SW_TRANSITION = 7;
+ public const int NW_TRANSITION = 8;
+ public const int NE_NW_TRANSITION = 9;
+ public const int NW_SE_TRANSITION = 10;
+ public const int NE_NW_SE_TRANSITION = 11;
+ public const int NW_SW_TRANSITION = 12;
+ public const int NE_NW_SW_TRANSITION = 13;
+ public const int NW_SE_SW_TRANSITION = 14;
+ public const int TRANSITION_TO_ALL_DIRECTIONS = 15;
+
+ // Array / matrix index definitions
+ public const int NE_INDEX = 0;
+ public const int NW_INDEX = 1;
+ public const int CENTER_INDEX = 2;
+ public const int SE_INDEX = 3;
+ public const int SW_INDEX = 4;
+
+ // Transition arrays for different transition types.
+ // These are used to check which transition type a tile should use by checking the nearby tiles.
+ // 1 means that the tile matches the ground type being placed, 0 means that the tile has something else.
+ // If you "rotate" these 45 degrees clockwise, these match the isometric perspective.
+ public static readonly int[] A_SURROUNDS_TILE = new int[]
+ { 1,
+ 1,0,1,
+ 1 };
+
+ public static readonly int[] A_NE_TRANSITION = new int[]
+ { 0,
+ 1,1,1,
+ 1 };
+
+ public static readonly int[] A_SE_TRANSITION = new int[]
+ { 1,
+ 1,1,0,
+ 1 };
+
+ public static readonly int[] A_NE_SE_TRANSITION = new int[]
+ { 0,
+ 1,1,0,
+ 1 };
+
+ public static readonly int[] A_SW_TRANSITION = new int[]
+ { 1,
+ 1,1,1,
+ 0 };
+
+ public static readonly int[] A_NE_SW_TRANSITION = new int[]
+ { 0,
+ 1,1,1,
+ 0 };
+
+ public static readonly int[] A_SE_SW_TRANSITION = new int[]
+ { 1,
+ 1,1,0,
+ 0 };
+
+ public static readonly int[] A_NE_SE_SW_TRANSITION = new int[]
+ { 0,
+ 1,1,0,
+ 0 };
+
+ public static readonly int[] A_NW_TRANSITION = new int[]
+ { 1,
+ 0,1,1,
+ 1 };
+
+ public static readonly int[] A_NE_NW_TRANSITION = new int[]
+ { 0,
+ 0,1,1,
+ 1 };
+
+ public static readonly int[] A_NW_SE_TRANSITION = new int[]
+ { 1,
+ 0,1,0,
+ 1 };
+
+ public static readonly int[] A_NE_NW_SE_TRANSITION = new int[]
+ { 0,
+ 0,1,0,
+ 1 };
+
+ public static readonly int[] A_NW_SW_TRANSITION = new int[]
+ { 1,
+ 0,1,1,
+ 0 };
+
+ public static readonly int[] A_NE_NW_SW_TRANSITION = new int[]
+ { 0,
+ 0,1,1,
+ 0 };
+
+ public static readonly int[] A_NW_SE_SW_TRANSITION = new int[]
+ { 1,
+ 0,1,0,
+ 0 };
+
+ public static readonly int[] A_TRANSITION_TO_ALL_DIRECTIONS = new int[]
+ { 0,
+ 0,1,0,
+ 0 };
+
+ ///
+ /// Links transition indexes and related data arrays.
+ ///
+ public static AutoLATData[] AutoLATData { get; private set; }
+
+ public static void InitArray()
+ {
+ AutoLATData = new AutoLATData[]
+ {
+ new AutoLATData(SURROUNDS_TILE, A_SURROUNDS_TILE),
+ new AutoLATData(NE_TRANSITION, A_NE_TRANSITION),
+ new AutoLATData(SE_TRANSITION, A_SE_TRANSITION),
+ new AutoLATData(NE_SE_TRANSITION, A_NE_SE_TRANSITION),
+ new AutoLATData(SW_TRANSITION, A_SW_TRANSITION),
+ new AutoLATData(NE_SW_TRANSITION, A_NE_SW_TRANSITION),
+ new AutoLATData(SE_SW_TRANSITION, A_SE_SW_TRANSITION),
+ new AutoLATData(NE_SE_SW_TRANSITION, A_NE_SE_SW_TRANSITION),
+ new AutoLATData(NW_TRANSITION, A_NW_TRANSITION),
+ new AutoLATData(NE_NW_TRANSITION, A_NE_NW_TRANSITION),
+ new AutoLATData(NW_SE_TRANSITION, A_NW_SE_TRANSITION),
+ new AutoLATData(NE_NW_SE_TRANSITION, A_NE_NW_SE_TRANSITION),
+ new AutoLATData(NW_SW_TRANSITION, A_NW_SW_TRANSITION),
+ new AutoLATData(NE_NW_SW_TRANSITION, A_NE_NW_SW_TRANSITION),
+ new AutoLATData(NW_SE_SW_TRANSITION, A_NW_SE_SW_TRANSITION),
+ new AutoLATData(TRANSITION_TO_ALL_DIRECTIONS, A_TRANSITION_TO_ALL_DIRECTIONS),
+ };
+ }
+}
diff --git a/src/MapEditorLibrary/CCEngine/BuildingWithPropertyType.cs b/src/MapEditorLibrary/CCEngine/BuildingWithPropertyType.cs
new file mode 100644
index 000000000..9ec2afb4a
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/BuildingWithPropertyType.cs
@@ -0,0 +1,25 @@
+namespace MapEditorLibrary.CCEngine;
+
+public enum BuildingWithPropertyType
+{
+ LeastThreat = 0x0,
+ HighestThreat = 0x10000,
+ Nearest = 0x20000,
+ Farthest = 0x30000,
+ Invalid = 0x40000
+}
+
+public static class BuildingWithPropertyTypeExtension
+{
+ public static string ToDescription(this BuildingWithPropertyType value)
+ {
+ return value switch
+ {
+ BuildingWithPropertyType.LeastThreat => Translate("BuildingWithPropertyTypeExtension.LeastThreat", "Least threat"),
+ BuildingWithPropertyType.HighestThreat => Translate("BuildingWithPropertyTypeExtension.HighestThreat", "Highest threat"),
+ BuildingWithPropertyType.Nearest => Translate("BuildingWithPropertyTypeExtension.Nearest", "Nearest"),
+ BuildingWithPropertyType.Farthest => Translate("BuildingWithPropertyTypeExtension.Farthest", "Farthest"),
+ _ => string.Empty,
+ };
+ }
+}
diff --git a/src/TSMapEditor/CCEngine/CCCrypto.cs b/src/MapEditorLibrary/CCEngine/CCCrypto.cs
similarity index 99%
rename from src/TSMapEditor/CCEngine/CCCrypto.cs
rename to src/MapEditorLibrary/CCEngine/CCCrypto.cs
index 425737c7f..e8c4d8f73 100644
--- a/src/TSMapEditor/CCEngine/CCCrypto.cs
+++ b/src/MapEditorLibrary/CCEngine/CCCrypto.cs
@@ -1,10 +1,7 @@
-using System;
-using System.Buffers.Binary;
-using System.IO;
-using System.Linq;
+using System.Buffers.Binary;
using System.Numerics;
-namespace TSMapEditor.CCEngine;
+namespace MapEditorLibrary.CCEngine;
///
/// Allows to obtain Blowfish key from its encrypted form stored in MIX files.
diff --git a/src/MapEditorLibrary/CCEngine/CCFileManager.cs b/src/MapEditorLibrary/CCEngine/CCFileManager.cs
new file mode 100644
index 000000000..f6f8cc428
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/CCFileManager.cs
@@ -0,0 +1,338 @@
+using MapEditorLibrary.Misc;
+using Rampastring.Tools;
+
+namespace MapEditorLibrary.CCEngine;
+
+public class CCFileManager
+{
+ public string GameDirectory { get; set; }
+
+ public bool LogFileLoading { get; set; }
+
+ private List searchDirectories = new();
+
+ ///
+ /// Contains information on which MIX file each found game file can be loaded from.
+ ///
+ private Dictionary fileLocationInfos = new();
+
+ ///
+ /// List of all MIX files that have been registered with the file manager.
+ ///
+ private List mixFiles = new();
+
+ ///
+ /// List of all CSF files that have been registered with the file manager.
+ ///
+ public List CsfFiles { get; } = new();
+
+ public void ReadConfig()
+ {
+ var iniFile = Helpers.ReadConfigINI("FileManagerConfig.ini");
+
+ AddSearchDirectory(Environment.CurrentDirectory);
+ iniFile.DoForEveryValueInSection("SearchDirectories", v => AddSearchDirectory(Path.Combine(GameDirectory, v)));
+ iniFile.DoForEveryValueInSection("MIXFiles", ProcessMixFileEntry);
+ iniFile.DoForEveryValueInSection("StringTables", LoadStringTable);
+ }
+
+ ///
+ /// Adds a directory to the list of directories where files will be
+ /// searched from.
+ ///
+ /// The path to the directory.
+ public void AddSearchDirectory(string path)
+ {
+ searchDirectories.Add(Helpers.NormalizePath(path));
+ }
+
+ ///
+ /// Processes an entry in the MIXFiles list.
+ /// Loads a required or optional MIX file
+ /// or handles a special entry.
+ ///
+ /// Contents of an entry of the MIXFiles list.
+ private void ProcessMixFileEntry(string entry)
+ {
+ var parts = entry.Split(',', StringSplitOptions.TrimEntries);
+ string mixName = parts[0];
+
+ if (IsSpecialMixName(mixName))
+ {
+ HandleSpecialMixName(mixName);
+ return;
+ }
+
+ bool isRequired = false;
+
+ if (parts.Length > 1)
+ isRequired = Conversions.BooleanFromString(parts[1], isRequired);
+
+ if (isRequired)
+ LoadRequiredMixFile(mixName);
+ else
+ LoadOptionalMixFile(mixName);
+ }
+
+ ///
+ /// Loads a MIX file.
+ /// Searches for it from both the search directories
+ /// as well as already loaded MIX files.
+ ///
+ /// The name of the MIX file.
+ /// True if the MIX file was successfully loaded, otherwise false.
+ private bool LoadMixFile(string name)
+ {
+ uint identifier = MixFile.GetFileID(name);
+
+ // First check from game directory, if not found then check from already loaded MIX files
+ if (!LoadMixFromDirectories(name))
+ {
+ if (fileLocationInfos.TryGetValue(identifier, out FileLocationInfo value))
+ {
+ // Logger.Log("Loading MIX file " + name + " from existing MIX file " + Path.GetFileName(value.MixFile.FilePath));
+ var mixFile = new MixFile(value.MixFile, value.Offset);
+ mixFile.Parse();
+ AddMix(mixFile);
+ return true;
+ }
+
+ Logger.Log("Failed to find MIX file: " + name);
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Attempts to search for and load a MIX file from the search directories.
+ /// Returns true if loading the MIX file succeeds, otherwise false.
+ ///
+ /// The name of the MIX file.
+ /// True if the MIX file was successfully loaded, otherwise false.
+ private bool LoadMixFromDirectories(string name)
+ {
+ string searchDir = null;
+
+ foreach (string dir in searchDirectories)
+ {
+ if (File.Exists(Path.Combine(dir, name)))
+ {
+ searchDir = dir;
+ break;
+ }
+ }
+
+ if (searchDir == null)
+ return false;
+
+ Logger.Log("Loading MIX file " + name + " from " + searchDir);
+ var mixFile = new MixFile();
+ mixFile.Parse(Path.Combine(searchDir, name));
+ AddMix(mixFile);
+
+ return true;
+ }
+
+ ///
+ /// Registers a MIX file to the file system.
+ /// Adds all file entries from the MIX file to the file location tracking system.
+ ///
+ /// The MIX file to register.
+ private void AddMix(MixFile mixFile)
+ {
+ mixFiles.Add(mixFile);
+
+ if (LogFileLoading)
+ Logger.Log("Registering " + mixFile.GetEntries().Count + " file entries from " + Path.GetFileName(mixFile.FilePath));
+
+ foreach (MixFileEntry fileEntry in mixFile.GetEntries())
+ {
+ if (fileLocationInfos.ContainsKey(fileEntry.Identifier))
+ continue;
+
+ fileLocationInfos[fileEntry.Identifier] = new FileLocationInfo(mixFile, fileEntry.Offset, fileEntry.Size);
+ }
+ }
+
+ ///
+ /// Loads a required MIX file.
+ /// Throws a FileNotFoundException if the MIX file isn't found.
+ ///
+ /// The name of the MIX file.
+ public void LoadRequiredMixFile(string name)
+ {
+ if (!LoadMixFile(name))
+ {
+ throw new FileNotFoundException("Required MIX file not found: " + name);
+ }
+ }
+
+ ///
+ /// Loads an optional MIX file.
+ /// Does not throw an exception if the MIX file is not found.
+ ///
+ /// The name of the MIX file.
+ public void LoadOptionalMixFile(string name)
+ {
+ if (!LoadMixFile(name))
+ {
+ Logger.Log("Optional MIX file not found: " + name);
+ }
+ }
+
+ ///
+ /// Loads MIX files of the format NAME##.
+ ///
+ /// The common name of the MIX files.
+ public void LoadIndexedMixFiles(string name)
+ {
+ for (int i = 99; i >= 0; i--)
+ LoadMixFile($"{name}{i:00}.mix");
+ }
+
+ ///
+ /// Loads MIX files with a wildcard.
+ ///
+ /// The common name of the MIX files.
+ public void LoadWildcardMixFiles(string name)
+ {
+ foreach (string searchDirectory in searchDirectories)
+ {
+ if (!Directory.Exists(searchDirectory))
+ continue;
+
+ var files = Directory.GetFiles(searchDirectory, name);
+ foreach (string file in files)
+ LoadMixFile(Path.GetFileName(file));
+ }
+ }
+
+ ///
+ /// Loads a required CSF file.
+ /// Throws a FileNotFoundException if the CSF file isn't found.
+ ///
+ /// The name of the CSf file.
+ public void LoadStringTable(string name)
+ {
+ var data = LoadFile(name);
+ if (data == null)
+ throw new FileNotFoundException("CSF file not found: " + name);
+ var file = new CsfFile(name);
+ file.ParseFromBuffer(data);
+ CsfFiles.Add(file);
+ }
+
+ ///
+ /// Searches for a file from all search directories.
+ /// If found, returns the full path to the found file.
+ /// Otherwise returns null.
+ ///
+ /// The name of the file to look for.
+ public string FindFileFromDirectories(string fileName)
+ {
+ foreach (string searchDirectory in searchDirectories)
+ {
+ string fullPath = Path.Combine(searchDirectory, fileName);
+
+ if (File.Exists(fullPath))
+ return fullPath;
+ }
+
+ return null;
+ }
+
+ public byte[] LoadFile(string name)
+ {
+ if (LogFileLoading)
+ Logger.Log("Loading file " + name);
+
+ foreach (string searchDirectory in searchDirectories)
+ {
+ string looseFilePath = Path.Combine(searchDirectory, name);
+ if (File.Exists(looseFilePath))
+ {
+ if (LogFileLoading)
+ Logger.Log(" File found from " + searchDirectory);
+
+ return File.ReadAllBytes(looseFilePath);
+ }
+ }
+
+ uint id = MixFile.GetFileID(name);
+
+ if (fileLocationInfos.TryGetValue(id, out FileLocationInfo value))
+ {
+ if (LogFileLoading)
+ Logger.Log(" File found from " + Path.GetFileName(value.MixFile.FilePath));
+
+ return value.MixFile.GetSingleFileData(value.Offset, value.Size);
+ }
+
+ if (LogFileLoading)
+ Logger.Log(" FAILED to find file: " + name);
+
+ return null;
+ }
+
+ private bool IsSpecialMixName(string name)
+ {
+ name = name.ToUpper();
+ switch (name)
+ {
+ case "$TSECACHE":
+ case "$RA2ECACHE":
+ case "$TSELOCAL":
+ case "$RA2ELOCAL":
+ case "$EXPAND":
+ case "$EXPANDMD":
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private void HandleSpecialMixName(string name)
+ {
+ name = name.ToUpper();
+ switch (name)
+ {
+ case "$TSECACHE":
+ LoadIndexedMixFiles("ecache");
+ break;
+ case "$RA2ECACHE":
+ LoadWildcardMixFiles("ecache*.mix");
+ break;
+ case "$TSELOCAL":
+ LoadIndexedMixFiles("elocal");
+ break;
+ case "$RA2ELOCAL":
+ LoadWildcardMixFiles("elocal*.mix");
+ break;
+ case "$EXPAND":
+ LoadIndexedMixFiles("expand");
+ break;
+ case "$EXPANDMD":
+ LoadIndexedMixFiles("expandmd");
+ break;
+ }
+ }
+}
+
+///
+/// Struct for holding data on which MIX file a file exists in,
+/// and where the file exists within the MIX file.
+///
+internal struct FileLocationInfo
+{
+ public FileLocationInfo(MixFile mixFile, int offset, int size)
+ {
+ MixFile = mixFile;
+ Offset = offset;
+ Size = size;
+ }
+
+ public MixFile MixFile { get; private set; }
+ public int Offset { get; private set; }
+ public int Size { get; private set; }
+}
diff --git a/src/MapEditorLibrary/CCEngine/CsfFile.cs b/src/MapEditorLibrary/CCEngine/CsfFile.cs
new file mode 100644
index 000000000..f616c8663
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/CsfFile.cs
@@ -0,0 +1,228 @@
+using MapEditorLibrary.Models;
+
+namespace MapEditorLibrary.CCEngine;
+
+
+public class CsfLoadException : Exception
+{
+ public CsfLoadException(string message) : base(message)
+ {
+ }
+}
+
+public enum CsfVersion
+{
+ Nox = 2,
+ Cnc = 3,
+}
+
+public enum CsfLanguage
+{
+ EnglishAmerican = 0,
+ EnglishBritish = 1,
+ German = 2,
+ French = 3,
+ Spanish = 4,
+ Italian = 5,
+ Japanese = 6,
+ Jabberwockie = 7, // According to ModEnc.
+ Korean = 8,
+ Chinese = 9,
+}
+
+///
+/// Represents a CSF file header. Most of the information here is not useful.
+///
+struct CsfFileHeader
+{
+ public const int SizeOf = 24;
+ private const uint CsfPrefix = 0x43534620; // " FSC" as a LE-uint32
+
+ public CsfFileHeader(byte[] buffer)
+ {
+ if (buffer.Length < SizeOf)
+ throw new CsfLoadException(nameof(CsfFileHeader) + ": buffer is not long enough");
+
+ if (BitConverter.ToUInt32(buffer, 0) != CsfPrefix)
+ throw new CsfLoadException(nameof(CsfFileHeader) + ": FSC prefix not found");
+
+ Version = (CsfVersion)BitConverter.ToUInt32(buffer, 4);
+ NumberOfLabels = BitConverter.ToUInt32(buffer, 8);
+ NumberOfStrings = BitConverter.ToUInt32(buffer, 12);
+ Unused = BitConverter.ToUInt32(buffer, 16);
+ Language = (CsfLanguage)BitConverter.ToUInt32(buffer, 20);
+ }
+
+ public CsfVersion Version;
+ public uint NumberOfLabels;
+ public uint NumberOfStrings;
+ public uint Unused;
+ public CsfLanguage Language;
+}
+
+///
+/// Represents a CSF (stringtable) file. Contains several string labels: text key-value pairs.
+///
+public class CsfFile
+{
+ private const uint LblPrefix = 0x4C424C20; // " LBL" as a LE-uint32
+ private const uint StrPrefix = 0x53545220; // " RTS" as a LE-uint32
+ private const uint StrwPrefix = 0x53545257; // "WRTS" as a LE-uint32
+
+ public CsfFile() { }
+
+ public CsfFile(string fileName)
+ {
+ this.fileName = fileName;
+ }
+
+ private readonly string fileName;
+ private CsfFileHeader csfFileHeader;
+
+ public CsfString[] Strings { get; private set; }
+
+ ///
+ /// Creates a CSf file from a directory + path or from MIX file system.
+ ///
+ /// Path to file or file name inside MIX file system.
+ /// The path to the game directory.
+ /// File manager object holding MIXes.
+ /// Loaded CSF file, or empty CSF file object if the file was not found.
+ public static CsfFile FromPathOrMix(string filePath, string gameDirectory, CCFileManager ccFileManager)
+ {
+ if (filePath.Length == 0)
+ return new();
+
+ string path = Path.Combine(gameDirectory, filePath);
+ if (File.Exists(path))
+ {
+ var file = new CsfFile(path);
+ file.ParseFromFile(path);
+
+ return file;
+ }
+
+ var bytes = ccFileManager.LoadFile(filePath);
+ if (bytes != null)
+ {
+ var file = new CsfFile(filePath);
+ file.ParseFromBuffer(bytes);
+ }
+
+ return new();
+ }
+
+ public void ParseFromFile(string filePath)
+ {
+ using (FileStream stream = File.OpenRead(filePath))
+ {
+ Parse(stream);
+ }
+ }
+
+ public void Parse(Stream stream)
+ {
+ byte[] buffer = new byte[stream.Length];
+ stream.Position = 0;
+ stream.Read(buffer, 0, buffer.Length);
+ ParseFromBuffer(buffer);
+ }
+
+ public void ParseFromBuffer(byte[] buffer)
+ {
+ try
+ {
+ csfFileHeader = new CsfFileHeader(buffer);
+ var strings = new List((int)csfFileHeader.NumberOfLabels);
+
+ using (var memoryStream = new MemoryStream(buffer))
+ {
+ memoryStream.Position = CsfFileHeader.SizeOf;
+ for (int i = 0; i < csfFileHeader.NumberOfLabels; i++)
+ strings.Add(ParseLabel(memoryStream));
+ }
+
+ Strings = strings.ToArray();
+ }
+ catch (CsfLoadException ex)
+ {
+ throw new CsfLoadException("Failed to load CSF file. Make sure that the file is not corrupted. Filename: " + fileName + ", original exception: " + ex.Message);
+ }
+ }
+
+ ///
+ /// Parse a CsfString from a stream.
+ ///
+ /// Input stream.
+ ///
+ public CsfString ParseLabel(MemoryStream memoryStream)
+ {
+ var buffer = new byte[4];
+ memoryStream.Read(buffer, 0, 4);
+
+ if (BitConverter.ToUInt32(buffer) != LblPrefix)
+ throw new CsfLoadException(nameof(CsfFile) + ": LBL prefix not found");
+
+ memoryStream.Read(buffer, 0, 4);
+ uint numberOfPairs = BitConverter.ToUInt32(buffer, 0);
+ memoryStream.Read(buffer, 0, 4);
+ uint labelLength = BitConverter.ToUInt32(buffer, 0);
+
+ var labelBuffer = new byte[labelLength];
+ memoryStream.Read(labelBuffer, 0, labelBuffer.Length);
+ var csfLabel = System.Text.Encoding.ASCII.GetString(labelBuffer);
+
+ var csfString = ParseString(memoryStream, buffer);
+ for (uint i = 1; i < numberOfPairs; i++)
+ ParseString(memoryStream, buffer, skip: true);
+
+ return new CsfString(csfLabel, csfString);
+ }
+
+ ///
+ /// Parse a single string from a stream.
+ ///
+ /// Input stream.
+ /// 4 byte long temporary buffer.
+ /// Only advance the stream and return null.
+ /// Read string or null if `skip` was true.
+ ///
+ public string ParseString(MemoryStream memoryStream, byte[] buffer, bool skip = false)
+ {
+ memoryStream.Read(buffer, 0, 4);
+ uint prefix = BitConverter.ToUInt32(buffer);
+ bool hasExtra = prefix == StrwPrefix;
+
+ if ((prefix != StrPrefix) && !hasExtra)
+ throw new CsfLoadException(nameof(CsfFile) + ": STR/STRW prefix not found");
+
+ memoryStream.Read(buffer, 0, 4);
+ uint stringLength = BitConverter.ToUInt32(buffer, 0);
+ string csfString = null;
+
+ if (!skip)
+ {
+ var stringBuffer = new byte[stringLength * 2];
+ memoryStream.Read(stringBuffer, 0, stringBuffer.Length);
+ // Westwood's "encoding".
+ for (uint i = 0; i < stringBuffer.Length; i++)
+ stringBuffer[i] = (byte)~stringBuffer[i];
+
+ csfString = System.Text.Encoding.Unicode.GetString(stringBuffer);
+ }
+ else
+ {
+ memoryStream.Position += stringLength;
+ }
+
+ if (hasExtra)
+ {
+ memoryStream.Read(buffer, 0, 4);
+ uint extraLength = BitConverter.ToUInt32(buffer, 0);
+ // Skip the extra data, as it's unused by the game.
+ memoryStream.Position += extraLength;
+ }
+
+ return csfString;
+ }
+}
diff --git a/src/MapEditorLibrary/CCEngine/GameConfigINIFiles.cs b/src/MapEditorLibrary/CCEngine/GameConfigINIFiles.cs
new file mode 100644
index 000000000..4745ecc16
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/GameConfigINIFiles.cs
@@ -0,0 +1,37 @@
+using MapEditorLibrary.Extensions;
+using Rampastring.Tools;
+
+namespace MapEditorLibrary.CCEngine;
+
+public class GameConfigINIFiles
+{
+ public GameConfigINIFiles(string gameDirectory, CCFileManager fileManager)
+ {
+ RulesIni = IniFileEx.FromPathOrMix(Constants.RulesIniPath, gameDirectory, fileManager, true);
+ FirestormIni = IniFileEx.FromPathOrMix(Constants.FirestormIniPath, gameDirectory, fileManager, true);
+
+ if (RulesIni == null && FirestormIni == null)
+ throw new FileNotFoundException("No Rules.ini found! (including derivates like Firestorm.ini / Rulesmd.ini)");
+
+ if (RulesIni == null)
+ RulesIni = new IniFileEx();
+
+ if (FirestormIni == null)
+ FirestormIni = new IniFileEx();
+
+ ArtIni = IniFileEx.FromPathOrMix(Constants.ArtIniPath, gameDirectory, fileManager);
+ ArtFSIni = IniFileEx.FromPathOrMix(Constants.FirestormArtIniPath, gameDirectory, fileManager);
+ AIIni = IniFileEx.FromPathOrMix(Constants.AIIniPath, gameDirectory, fileManager);
+ AIFSIni = IniFileEx.FromPathOrMix(Constants.FirestormAIIniPath, gameDirectory, fileManager);
+
+ IniFile artOverridesIni = Helpers.ReadConfigINI("ArtOverrides.ini");
+ IniFile.ConsolidateIniFiles(ArtFSIni, artOverridesIni);
+ }
+
+ public IniFileEx RulesIni { get; }
+ public IniFileEx FirestormIni { get; }
+ public IniFileEx ArtIni { get; }
+ public IniFileEx ArtFSIni { get; }
+ public IniFileEx AIIni { get; }
+ public IniFileEx AIFSIni { get; }
+}
diff --git a/src/MapEditorLibrary/CCEngine/HvaFile.cs b/src/MapEditorLibrary/CCEngine/HvaFile.cs
new file mode 100644
index 000000000..d12a28cf0
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/HvaFile.cs
@@ -0,0 +1,100 @@
+using CNCMaps.FileFormats.VirtualFileSystem;
+using Microsoft.Xna.Framework;
+
+namespace MapEditorLibrary.CCEngine;
+
+public class HvaLoadException : Exception
+{
+ public HvaLoadException(string message) : base(message) { }
+}
+
+///
+/// .hva file format
+/// Based on the CNCMaps Renderer code
+/// https://github.com/zzattack/ccmaps-net
+///
+
+public class HvaFile : VirtualFile
+{
+ public int NumFrames { get; set; }
+ public List Sections { get; set; }
+
+ public class Section
+ {
+ public string Name;
+ public List Matrices;
+ public Section(int numMatrices)
+ {
+ Matrices = new List(numMatrices);
+ }
+ }
+
+ public HvaFile(Stream baseStream, string filename, int baseOffset, int fileSize, bool isBuffered = true)
+ : base(baseStream, filename, baseOffset, fileSize, isBuffered)
+ {
+ Initialize();
+ }
+
+ public HvaFile(Stream baseStream, string filename = "", bool isBuffered = true)
+ : base(baseStream, filename, isBuffered)
+ {
+ Initialize();
+ }
+
+ public HvaFile(byte[] buffer, string filename = "") : base(new MemoryStream(buffer), filename, true)
+ {
+ Initialize();
+ }
+
+ private void Initialize()
+ {
+ Seek(0, SeekOrigin.Begin);
+ ReadCString(16); // filename
+ NumFrames = ReadInt32();
+ int numSections = ReadInt32();
+ Sections = new List(numSections);
+
+ for (int i = 0; i < numSections; i++)
+ {
+ Sections.Add(new Section(NumFrames)
+ {
+ Name = ReadCString(16)
+ });
+ }
+
+ for (int frame = 0; frame < NumFrames; frame++)
+ {
+ for (int section = 0; section < Sections.Count; section++)
+ Sections[section].Matrices.Add(ReadMatrix());
+ }
+ }
+
+ private float[] ReadMatrix()
+ {
+ var ret = new float[12];
+ for (int i = 0; i < 12; i++)
+ {
+ ret[i] = ReadFloat();
+ }
+ return ret;
+ }
+
+ public Matrix LoadMatrix(string section, int frame = 0)
+ {
+ return ToMatrix(Sections.Find(s => s.Name == section).Matrices[frame]);
+ }
+
+ public Matrix LoadMatrix(int section, int frame = 0)
+ {
+ return ToMatrix(Sections[section].Matrices[frame]);
+ }
+
+ private static Matrix ToMatrix(float[] hvaMatrix)
+ {
+ return new Matrix(
+ hvaMatrix[0], hvaMatrix[4], hvaMatrix[8], 0,
+ hvaMatrix[1], hvaMatrix[5], hvaMatrix[9], 0,
+ hvaMatrix[2], hvaMatrix[6], hvaMatrix[10], 0,
+ hvaMatrix[3], hvaMatrix[7], hvaMatrix[11], 1);
+ }
+}
\ No newline at end of file
diff --git a/src/MapEditorLibrary/CCEngine/LayerType.cs b/src/MapEditorLibrary/CCEngine/LayerType.cs
new file mode 100644
index 000000000..c40046629
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/LayerType.cs
@@ -0,0 +1,10 @@
+namespace MapEditorLibrary.CCEngine;
+
+public enum LayerType
+{
+ Underground = -2,
+ Surface = -1,
+ Ground = 0,
+ Air = 1,
+ Top = 2
+}
diff --git a/src/MapEditorLibrary/CCEngine/MixCRC.cs b/src/MapEditorLibrary/CCEngine/MixCRC.cs
new file mode 100644
index 000000000..db0218293
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/MixCRC.cs
@@ -0,0 +1,56 @@
+namespace MapEditorLibrary.CCEngine;
+
+internal static class MixCRC
+{
+ // See Ccrc class in http://xhp.xwis.net/documents/MIX_Format.html
+
+ private static readonly uint[] crc_table = {
+ 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3,
+ 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91,
+ 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7,
+ 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5,
+ 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b,
+ 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59,
+ 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f,
+ 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d,
+ 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433,
+ 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01,
+ 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457,
+ 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65,
+ 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb,
+ 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9,
+ 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f,
+ 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad,
+ 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683,
+ 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1,
+ 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7,
+ 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5,
+ 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
+ 0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79,
+ 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f,
+ 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d,
+ 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713,
+ 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21,
+ 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777,
+ 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45,
+ 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db,
+ 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9,
+ 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf,
+ 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d};
+
+ public static uint GetCRC(byte[] data)
+ {
+ int index = 0;
+ uint crc = 0xFFFFFFFF;
+
+ do
+ {
+ byte tableIndex = (byte)(data[index] ^ (crc & 255));
+ crc = (crc >> 8) ^ crc_table[tableIndex];
+ index++;
+ }
+ while (index < data.Length);
+
+ return ~crc;
+ }
+}
diff --git a/src/MapEditorLibrary/CCEngine/MixFile.cs b/src/MapEditorLibrary/CCEngine/MixFile.cs
new file mode 100644
index 000000000..e698ad221
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/MixFile.cs
@@ -0,0 +1,317 @@
+using System.Text;
+
+namespace MapEditorLibrary.CCEngine;
+
+///
+/// A Tiberian Sun / Red Alert 2 type MIX file.
+///
+class MixFile
+{
+
+ private const int INDEX_POSITION = 10;
+
+
+ public MixFile() { }
+
+ public MixFile(MixFile masterMix, int startOffset)
+ {
+ this.masterMix = masterMix;
+ mixStartOffset = startOffset;
+
+ if (masterMix != null)
+ {
+ mixStartOffset += masterMix.bodyOffset;
+ }
+ }
+
+
+ public string FilePath { get; private set; }
+
+ private List entries;
+
+ private int bodyOffset;
+
+ private Stream stream;
+
+ ///
+ /// The MIX file that this MIX file resides in, if any.
+ ///
+ private MixFile masterMix;
+
+ ///
+ /// The start offset for this MIX file when it's inside another MIX file.
+ /// Should be zero if this MIX file is not inside another MIX file.
+ ///
+ private int mixStartOffset = 0;
+
+
+ private readonly object locker = new object();
+
+ ///
+ /// Reads MIX file information from a MIX file in the given file system path.
+ ///
+ /// The path to the MIX file.
+ public void Parse(string path)
+ {
+ if (masterMix != null)
+ throw new InvalidOperationException("Can't parse from a file when the MIX file is inside another MIX file.");
+
+ FilePath = path;
+
+ using (FileStream fileStream = File.OpenRead(path))
+ {
+ Parse(fileStream);
+ }
+ }
+
+ ///
+ /// Reads MIX file entry information from a stream.
+ ///
+ /// The stream. Can be null for MIX files that
+ /// reside inside another MIX file.
+ public void Parse(Stream stream = null)
+ {
+ if (masterMix != null)
+ {
+ masterMix.OpenFile();
+ stream = this.stream = masterMix.stream;
+ stream.Position = mixStartOffset;
+ }
+
+ if (stream.Length < INDEX_POSITION)
+ return;
+
+ entries = new List();
+
+ byte[] buffer = new byte[256];
+ stream.ReadExactly(buffer, 0, 4);
+ MixType mixType = (MixType)BitConverter.ToInt32(buffer, 0);
+
+ bool isEncrypted = (mixType & MixType.ENCRYPTED) != 0;
+
+ if (isEncrypted)
+ {
+ // Read and decrypt the Blowfish associated with this MIX.
+ stream.ReadExactly(buffer, 0, KeyDecryptor.SIZE_OF_ENCRYPTED_KEY);
+ stream = new BlowfishStream(stream, KeyDecryptor.DecryptBlowfishKey(buffer));
+ }
+
+ stream.ReadExactly(buffer, 0, MixFileHeader.SIZE_OF_HEADER);
+
+ MixFileHeader header = new MixFileHeader(buffer);
+
+ bodyOffset = INDEX_POSITION + MixFileEntry.SIZE_OF_FILE_ENTRY * header.FileCount;
+
+ if (isEncrypted)
+ {
+ // Account for Blowfish key and padding.
+ bodyOffset += KeyDecryptor.SIZE_OF_ENCRYPTED_KEY;
+ bodyOffset += (header.FileCount % 2) == 0 ? 2 : 6;
+ }
+
+ for (int i = 0; i < header.FileCount; i++)
+ {
+ if (stream.Position + MixFileEntry.SIZE_OF_FILE_ENTRY >= stream.Length)
+ throw new MixParseException("Invalid MIX file.");
+
+ stream.ReadExactly(buffer, 0, MixFileEntry.SIZE_OF_FILE_ENTRY);
+ entries.Add(new MixFileEntry(buffer));
+ }
+
+ if (masterMix != null)
+ masterMix.CloseFile();
+ }
+
+ ///
+ /// Returns a list of file entries in this MIX file.
+ ///
+ public List GetEntries()
+ {
+ // Return a copy of the list so the callee can't modify our original list
+ return new List(entries);
+ }
+
+ ///
+ /// Opens the MIX file for performing one or more read operations.
+ ///
+ public void OpenFile()
+ {
+ if (masterMix != null)
+ {
+ masterMix.OpenFile();
+ this.stream = masterMix.stream;
+ return;
+ }
+
+ if (FilePath == null)
+ throw new MixParseException("No MIX file path defined!");
+
+ if (stream == null || !stream.CanRead)
+ stream = File.OpenRead(FilePath);
+ }
+
+ public MixFileEntry? GetEntry(uint id)
+ {
+ int index = entries.FindIndex(e => e.Identifier == id);
+ if (index < 0)
+ return null;
+
+ return entries[index];
+ }
+
+ ///
+ /// Gets file data from the MIX file.
+ ///
+ /// The start offset from the MIX body.
+ /// The number of bytes to read.
+ /// A byte array.
+ public byte[] GetFileData(int offset, int count)
+ {
+ byte[] buffer = new byte[count];
+
+ stream.Position = mixStartOffset + bodyOffset + offset;
+ stream.ReadExactly(buffer, 0, count);
+
+ return buffer;
+ }
+
+ ///
+ /// Closes the MIX file.
+ ///
+ public void CloseFile()
+ {
+ stream.Close();
+ }
+
+ ///
+ /// Gets data for a single file from the MIX file.
+ ///
+ /// The start offset from the MIX body.
+ /// The number of bytes to read.
+ /// A byte array.
+ public byte[] GetSingleFileData(int offset, int count)
+ {
+ var lockObject = GetLockObject();
+
+ lock (lockObject)
+ {
+ OpenFile();
+ byte[] buffer = GetFileData(offset, count);
+ CloseFile();
+ return buffer;
+ }
+ }
+
+ private object GetLockObject()
+ {
+ if (masterMix != null)
+ return masterMix.GetLockObject();
+
+ return locker;
+ }
+
+ ///
+ /// Calculates and returns the internal ID of a file based on its name.
+ /// The ID is needed when finding files from inside MIX files.
+ ///
+ /// The filename.
+ ///
+ public static uint GetFileID(string fileName)
+ {
+ fileName = fileName.ToUpperInvariant();
+ int a = fileName.Length >> 2;
+ if ((fileName.Length & 3) > 0)
+ {
+ fileName += (char)(fileName.Length - (a << 2));
+ int i = 3 - ((fileName.Length - 1) & 3);
+ while (i-- > 0)
+ fileName = fileName + fileName[a << 2];
+ }
+
+ return MixCRC.GetCRC(Encoding.ASCII.GetBytes(fileName));
+ }
+}
+
+///
+/// The type of a MIX file.
+///
+[Flags]
+public enum MixType
+{
+ DEFAULT = 0,
+ CHECKSUMMED = 0x00010000,
+ ENCRYPTED = 0x00020000
+}
+
+///
+/// A MIX file header. Contains information on the number of files and the size
+/// of the body of the MIX file.
+///
+public struct MixFileHeader
+{
+ public const int SIZE_OF_HEADER = 6;
+
+ public MixFileHeader(byte[] buffer)
+ {
+ if (buffer.Length < SIZE_OF_HEADER)
+ throw new ArgumentException("buffer is not long enough");
+
+ FileCount = BitConverter.ToInt16(buffer, 0);
+ BodySize = BitConverter.ToInt32(buffer, 2);
+ }
+
+ ///
+ /// The number of files in the MIX file.
+ ///
+ public short FileCount { get; private set; }
+
+ ///
+ /// The size of the MIX file, excluding the header and index.
+ ///
+ public int BodySize { get; private set; }
+}
+
+///
+/// Contains information on a file stored inside a MIX file.
+///
+public struct MixFileEntry
+{
+ public const int SIZE_OF_FILE_ENTRY = 12;
+
+ public MixFileEntry(byte[] buffer)
+ {
+ if (buffer.Length < SIZE_OF_FILE_ENTRY)
+ throw new ArgumentException("buffer is not long enough");
+
+ Identifier = BitConverter.ToUInt32(buffer, 0);
+ Offset = BitConverter.ToInt32(buffer, 4);
+ Size = BitConverter.ToInt32(buffer, 8);
+ }
+
+ ///
+ /// The identifier used to identify the file instead of a normal name.
+ ///
+ public uint Identifier { get; private set; }
+
+ ///
+ /// The offset of the file, from the start of the body.
+ ///
+ public int Offset { get; private set; }
+
+ ///
+ /// The size of the file.
+ ///
+ public int Size { get; private set; }
+}
+
+public struct FileOffsetInfo
+{
+
+}
+
+public class MixParseException : Exception
+{
+ public MixParseException(string message) : base(message)
+ {
+ }
+}
diff --git a/src/MapEditorLibrary/CCEngine/Palette.cs b/src/MapEditorLibrary/CCEngine/Palette.cs
new file mode 100644
index 000000000..323de1422
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/Palette.cs
@@ -0,0 +1,119 @@
+using MapEditorLibrary.Models;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+
+namespace MapEditorLibrary.CCEngine;
+
+///
+/// Layer on top of the game palette to enable rendering paletted textures with it.
+///
+public class XNAPalette : Palette
+{
+ public XNAPalette(string name, byte[] buffer, GraphicsDevice graphicsDevice, bool hasFullyBrightColors) : base(name, buffer)
+ {
+ PaletteWithLight = new(name, buffer);
+ Texture = CreateTexture(graphicsDevice, this);
+ TextureWithLight = CreateTexture(graphicsDevice, PaletteWithLight);
+ HasFullyBrightColors = hasFullyBrightColors;
+ }
+
+ public XNAPalette(string name, RGBColor[] data, GraphicsDevice graphicsDevice, bool hasFullyBrightColors) : base(name, data)
+ {
+ PaletteWithLight = new(name, data);
+ Texture = CreateTexture(graphicsDevice, this);
+ TextureWithLight = CreateTexture(graphicsDevice, PaletteWithLight);
+ HasFullyBrightColors = hasFullyBrightColors;
+ }
+
+ private Texture2D Texture;
+ private Texture2D TextureWithLight;
+ private Palette PaletteWithLight;
+ private bool HasFullyBrightColors;
+
+ public void Dispose()
+ {
+ Texture?.Dispose();
+ TextureWithLight?.Dispose();
+
+ Texture = null;
+ TextureWithLight = null;
+ }
+
+ public Texture2D GetTexture()
+ {
+ return Texture;
+ }
+
+ public Palette GetPalette()
+ {
+ return this;
+ }
+
+ private Texture2D CreateTexture(GraphicsDevice graphicsDevice, Palette palette)
+ {
+ Texture2D texture = new(graphicsDevice, LENGTH, 1, false, SurfaceFormat.Color);
+
+ Color[] colorData = new Color[LENGTH];
+ colorData[0] = Color.Transparent;
+ for (int i = 1; i < colorData.Length; i++)
+ {
+ colorData[i] = palette.Data[i].ToXnaColor();
+ }
+ texture.SetData(colorData);
+
+ return texture;
+ }
+
+ private void AdjustColor(int i, Color[] colorData, MapColor color)
+ {
+ RGBColor newColor = Data[i] * color;
+ PaletteWithLight.Data[i] = newColor;
+ colorData[i] = newColor.ToXnaColor();
+ }
+
+ public void ApplyLighting(MapColor color)
+ {
+ Color[] colorData = new Color[LENGTH];
+ int last = HasFullyBrightColors ? LENGTH - 16 : 255;
+
+ for (int i = 1; i < last; i++)
+ AdjustColor(i, colorData, color);
+
+ AdjustColor(255, colorData, color);
+
+ TextureWithLight.SetData(colorData);
+ }
+}
+
+///
+/// A C&C Tiberian Sun or Red Alert 2 palette.
+///
+public class Palette
+{
+ public const int LENGTH = 256;
+
+ public Palette(string name, byte[] buffer)
+ {
+ Name = name;
+ Data = new RGBColor[LENGTH];
+ Parse(buffer);
+ }
+
+ public Palette(string name, RGBColor[] data)
+ {
+ Name = name;
+ Data = data;
+ }
+
+ public readonly string Name;
+
+ public RGBColor[] Data;
+
+ public void Parse(byte[] buffer)
+ {
+ for (int i = 0; i < Data.Length; i++)
+ {
+ Data[i] = new RGBColor(buffer, i * 3, 2);
+ }
+ }
+}
diff --git a/src/MapEditorLibrary/CCEngine/RGBColor.cs b/src/MapEditorLibrary/CCEngine/RGBColor.cs
new file mode 100644
index 000000000..778fac7d8
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/RGBColor.cs
@@ -0,0 +1,26 @@
+using Microsoft.Xna.Framework;
+
+namespace MapEditorLibrary.CCEngine;
+
+public struct RGBColor
+{
+ public RGBColor(byte[] buffer, int offset, int shift)
+ {
+ R = (byte)(buffer[offset] << shift);
+ G = (byte)(buffer[offset + 1] << shift);
+ B = (byte)(buffer[offset + 2] << shift);
+ }
+
+ public RGBColor(byte r, byte g, byte b)
+ {
+ R = r;
+ G = g;
+ B = b;
+ }
+
+ public byte R;
+ public byte G;
+ public byte B;
+
+ public Color ToXnaColor() => new Color(R, G, B);
+}
diff --git a/src/MapEditorLibrary/CCEngine/RampType.cs b/src/MapEditorLibrary/CCEngine/RampType.cs
new file mode 100644
index 000000000..0ddb76144
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/RampType.cs
@@ -0,0 +1,42 @@
+namespace MapEditorLibrary.CCEngine;
+
+///
+/// Defines the types of terrain ramps in the game.
+///
+/// Taken from TS++, TIBSUN_DEFINES.H
+/// https://github.com/Vinifera-Developers/TSpp
+/// Originally by CCHyper, adjusted for C# by Rampastring
+public enum RampType
+{
+ None = 0,
+
+ // Basic, two adjacent corners raised
+ West = 1,
+ North = 2,
+ East = 3,
+ South = 4,
+
+ // Tile outside corners (one corner raised by half a cell)
+ CornerNW = 5,
+ CornerNE = 6,
+ CornerSE = 7,
+ CornerSW = 8,
+
+ // Tile inside corners (three corners raised by half a cell)
+ MidNW = 9,
+ MidNE = 10,
+ MidSE = 11,
+ MidSW = 12,
+
+ // Full tile sloped (mid corners raised by half cell, far corner by full cell)
+ SteepSE = 13,
+ SteepSW = 14,
+ SteepNW = 15,
+ SteepNE = 16,
+
+ // Double ramps (two corners raised, alternating)
+ DoubleUpSWNE = 17,
+ DoubleDownSWNE = 18,
+ DoubleUpNWSE = 19,
+ DoubleDownNWSE = 20
+}
diff --git a/src/MapEditorLibrary/CCEngine/ScriptAction.cs b/src/MapEditorLibrary/CCEngine/ScriptAction.cs
new file mode 100644
index 000000000..ad592c1d3
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/ScriptAction.cs
@@ -0,0 +1,97 @@
+using MapEditorLibrary.Models.Enums;
+using Rampastring.Tools;
+using System.Globalization;
+
+namespace MapEditorLibrary.CCEngine;
+
+public class ScriptActionPresetOption
+{
+ public int Value;
+ public string Text;
+
+ public ScriptActionPresetOption(int value, string text)
+ {
+ Value = value;
+ Text = text;
+ }
+
+ public string GetOptionText()
+ {
+ if (string.IsNullOrEmpty(Text))
+ return Value.ToString();
+ else
+ return Value + " - " + Text;
+ }
+}
+
+public class ScriptAction
+{
+ public ScriptAction(int id)
+ {
+ ID = id;
+ }
+
+ public int ID { get; set; }
+ public string Name { get; set; } = Translate("ScriptAction.UnknownAction", "Unknown action");
+ public string Description { get; set; } = Translate("ScriptAction.NoDescription", "No description");
+ public string ParamDescription { get; set; } = Translate("ScriptAction.Use0", "Use 0");
+ public string OptionsSectionName { get; set; } = string.Empty;
+ public TriggerParamType ParamType { get; set; } = TriggerParamType.Unknown;
+ public List PresetOptions { get; } = new List(0);
+ public bool UseWindowSelection { get; set; } = false;
+
+ public void ReadIniSection(IniFile iniFile, string sectionName)
+ {
+ var iniSection = iniFile.GetSection(sectionName);
+ ID = iniSection.GetIntValue("IDOverride", ID);
+ string untranslatedName = iniSection.GetStringValue(nameof(Name), Name);
+ Name = Translate(this, untranslatedName + ".Name", untranslatedName);
+ Description = Translate(this, untranslatedName + ".Description", iniSection.GetStringValue(nameof(Description), Description));
+ OptionsSectionName = iniSection.GetStringValue(nameof(OptionsSectionName), OptionsSectionName);
+ ParamDescription = iniSection.GetStringValue(nameof(ParamDescription), ParamDescription);
+ UseWindowSelection = iniSection.GetBooleanValue(nameof(UseWindowSelection), UseWindowSelection);
+ if (Enum.TryParse(iniSection.GetStringValue(nameof(ParamType), "Unknown"), out TriggerParamType result))
+ {
+ ParamType = result;
+ }
+
+ var optionsSection = iniSection;
+ string extraDesc = string.Empty;
+ if (!string.IsNullOrEmpty(OptionsSectionName) && iniFile.SectionExists(OptionsSectionName))
+ {
+ optionsSection = iniFile.GetSection(OptionsSectionName);
+ extraDesc = $"(Options section: {OptionsSectionName}) ";
+ }
+
+ int i = 0;
+ while (true)
+ {
+ string key = "Option" + i;
+
+ if (!optionsSection.KeyExists(key))
+ break;
+
+ string value = optionsSection.GetStringValue(key, null);
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ Logger.Log($"Invalid {key}= in ScriptAction {extraDesc}" + iniSection.SectionName);
+ break;
+ }
+
+ int commaIndex = value.IndexOf(',');
+ if (commaIndex < 0)
+ {
+ Logger.Log($"Invalid {key}= in ScriptAction {extraDesc}" + iniSection.SectionName);
+ break;
+ }
+
+ int presetValue = Conversions.IntFromString(value.Substring(0, commaIndex), 0);
+ string presetText = value.Substring(commaIndex + 1);
+ presetText = Translate(this, untranslatedName + ".Option" + i.ToString(CultureInfo.InvariantCulture) + ".PresetText", presetText);
+
+ PresetOptions.Add(new ScriptActionPresetOption(presetValue, presetText));
+
+ i++;
+ }
+ }
+}
diff --git a/src/MapEditorLibrary/CCEngine/ShpFile.cs b/src/MapEditorLibrary/CCEngine/ShpFile.cs
new file mode 100644
index 000000000..bb4b3cb9a
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/ShpFile.cs
@@ -0,0 +1,267 @@
+namespace MapEditorLibrary.CCEngine;
+
+public class ShpLoadException : Exception
+{
+ public ShpLoadException(string message) : base(message)
+ {
+ }
+}
+
+[Flags]
+public enum ShpCompression
+{
+ None = 0,
+ HasTransparency = 1,
+ UsesRle = 2
+}
+
+///
+/// Represents the header of a SHP file.
+///
+struct ShpFileHeader
+{
+ public const int SizeOf = 8;
+
+ public ShpFileHeader(byte[] buffer)
+ {
+ if (buffer.Length < SizeOf)
+ throw new ShpLoadException(nameof(ShpFileHeader) + ": buffer is not long enough");
+
+ Unknown = BitConverter.ToUInt16(buffer, 0);
+ if (Unknown != 0)
+ throw new ShpLoadException("Unexpected field value in SHP header");
+
+ SpriteWidth = BitConverter.ToUInt16(buffer, 2);
+ SpriteHeight = BitConverter.ToUInt16(buffer, 4);
+ FrameCount = BitConverter.ToUInt16(buffer, 6);
+ }
+
+ public ushort Unknown;
+ public ushort SpriteWidth;
+ public ushort SpriteHeight;
+ public ushort FrameCount;
+}
+
+///
+/// Represents the information of a single frame in a SHP file.
+///
+public class ShpFrameInfo
+{
+ private const int SizeOf = 24;
+
+ public ShpFrameInfo(Stream stream)
+ {
+ if (stream.Length < stream.Position + SizeOf)
+ throw new ShpLoadException(nameof(ShpFrameInfo) + ": buffer is not long enough");
+
+ XOffset = ReadUShortFromStream(stream);
+ YOffset = ReadUShortFromStream(stream);
+ Width = ReadUShortFromStream(stream);
+ Height = ReadUShortFromStream(stream);
+ Flags = (ShpCompression)ReadUIntFromStream(stream);
+ byte r = (byte)stream.ReadByte();
+ byte g = (byte)stream.ReadByte();
+ byte b = (byte)stream.ReadByte();
+ AverageColor = new RGBColor(r, g, b);
+ Unknown1 = (byte)stream.ReadByte();
+ Unknown2 = ReadUIntFromStream(stream);
+ DataOffset = ReadUIntFromStream(stream);
+ }
+
+ private ushort ReadUShortFromStream(Stream stream)
+ {
+ stream.Read(buffer, 0, 2);
+ return BitConverter.ToUInt16(buffer, 0);
+ }
+
+ private uint ReadUIntFromStream(Stream stream)
+ {
+ stream.Read(buffer, 0, 4);
+ return BitConverter.ToUInt32(buffer, 0);
+ }
+
+ byte[] buffer = new byte[4];
+
+ public ushort XOffset;
+ public ushort YOffset;
+ public ushort Width;
+ public ushort Height;
+ public ShpCompression Flags;
+ public RGBColor AverageColor;
+ public byte Unknown1;
+ public uint Unknown2;
+ public uint DataOffset;
+}
+
+///
+/// Represents a SHP file. Combines the header and frame information
+/// and makes it possible to parse the actual graphical data.
+///
+public class ShpFile
+{
+ public ShpFile() { }
+
+ public ShpFile(string fileName)
+ {
+ this.fileName = fileName;
+ }
+
+ private readonly string fileName;
+
+ private ShpFileHeader shpFileHeader;
+ private List shpFrameInfos;
+
+
+
+ public int FrameCount => shpFrameInfos.Count;
+
+ public int Width => shpFileHeader.SpriteWidth;
+ public int Height => shpFileHeader.SpriteHeight;
+
+ public void ParseFromFile(string filePath)
+ {
+ using (FileStream stream = File.OpenRead(filePath))
+ {
+ Parse(stream);
+ }
+ }
+
+ public void Parse(Stream stream)
+ {
+ byte[] buffer = new byte[stream.Length];
+ stream.Position = 0;
+ stream.Read(buffer, 0, buffer.Length);
+ ParseFromBuffer(buffer);
+ }
+
+ public void ParseFromBuffer(byte[] buffer)
+ {
+ try
+ {
+ shpFileHeader = new ShpFileHeader(buffer);
+ shpFrameInfos = new List(shpFileHeader.FrameCount);
+
+ using (var memoryStream = new MemoryStream(buffer))
+ {
+ memoryStream.Position = ShpFileHeader.SizeOf;
+
+ for (int i = 0; i < shpFileHeader.FrameCount; i++)
+ {
+ var shpFrameInfo = new ShpFrameInfo(memoryStream);
+ shpFrameInfos.Add(shpFrameInfo);
+ }
+ }
+ }
+ catch (ShpLoadException ex)
+ {
+ throw new ShpLoadException("Failed to load SHP file. Make sure that the file is not corrupted. Filename: " + fileName + ", original exception: " + ex.Message);
+ }
+ }
+
+ public ShpFrameInfo GetShpFrameInfo(int frameIndex) => shpFrameInfos[frameIndex];
+
+ public byte[] GetUncompressedFrameData(int frameIndex, byte[] fileData)
+ {
+ ShpFrameInfo frameInfo = shpFrameInfos[frameIndex];
+
+ if (frameInfo.DataOffset == 0)
+ return null;
+
+ byte[] frameData = new byte[frameInfo.Width * frameInfo.Height];
+
+ if ((frameInfo.Flags & ShpCompression.UsesRle) == ShpCompression.None)
+ {
+ for (int i = 0; i < frameData.Length; i++)
+ {
+ frameData[i] = fileData[frameInfo.DataOffset + i];
+ }
+ }
+ else
+ {
+ DecompressRLEZero(frameData, frameIndex, frameInfo, fileData);
+ }
+
+ return frameData;
+ }
+
+ private void DecompressRLEZero(byte[] frameData, int frameIndex, ShpFrameInfo frameInfo, byte[] fileData)
+ {
+ // https://moddingwiki.shikadi.net/wiki/Westwood_RLE-Zero
+
+ int dataOffset = 0;
+
+ // Read SHP line-by-line. RLE-zero only compresses the transparent parts (zero bytes) of each line, individually.
+ for (int lineIndex = 0; lineIndex < frameInfo.Height; lineIndex++)
+ {
+ int lineDataStartOffset = (int)frameInfo.DataOffset + dataOffset;
+
+ // Compose little-endian UInt16 from 2 bytes
+ int lineDataLength = fileData[lineDataStartOffset] | (fileData[lineDataStartOffset + 1] << 8);
+
+ if (lineDataStartOffset + lineDataLength > fileData.Length || lineDataLength < 2)
+ {
+ throw new ShpLoadException($"Line data length out-of-bounds in SHP RLE-Zero frame. " +
+ $"File name: {fileName}, frame index: {frameIndex}, line data length: {lineDataLength}");
+ }
+
+ // Line length includes the two-byte line data length value, skip it
+ int currentByteIndex = 2;
+
+ // Define variable for current pixel # on the current line
+ int pixelPositionOnLine = 0;
+
+ // Read image damage of current line
+ while (currentByteIndex < lineDataLength)
+ {
+ byte value = fileData[lineDataStartOffset + currentByteIndex];
+
+ if (value == 0)
+ {
+ // If we are at the end of a line when we encounter a zero, something is wrong.
+ if (currentByteIndex == lineDataLength - 1)
+ {
+ throw new ShpLoadException($"Zero-byte encountered at end of line data in SHP RLE-Zero frame. " +
+ $"File name: {fileName}, line index: {lineIndex}, file offset: {lineDataStartOffset + currentByteIndex}");
+ }
+
+ // A zero value means transparent pixels. The following byte
+ // defines how many pixels are transparent.
+ byte transparentPixelCount = fileData[lineDataStartOffset + currentByteIndex + 1];
+
+ // -1 prevents us from counting the current pixel "twice"
+ if (pixelPositionOnLine + transparentPixelCount - 1 > frameInfo.Width)
+ {
+ throw new ShpLoadException($"Out-of-bounds pixel position on transparent data in SHP RLE-Zero frame. " +
+ $"File name: {fileName}, frame index: {frameIndex}, line index: {lineIndex}, file offset: {lineDataStartOffset + currentByteIndex}");
+ }
+
+ // Assign transparent pixel data
+ while (transparentPixelCount > 0)
+ {
+ frameData[lineIndex * frameInfo.Width + pixelPositionOnLine] = 0;
+ pixelPositionOnLine++;
+ transparentPixelCount--;
+ }
+
+ // Advance buffer position
+ currentByteIndex += 2;
+ }
+ else
+ {
+ if (pixelPositionOnLine >= frameInfo.Width)
+ {
+ throw new ShpLoadException($"Out-of-bounds pixel position on color data in SHP RLE-Zero frame. " +
+ $"File name: {fileName}, frame index: {frameIndex}, line index: {lineIndex}, file offset: {lineDataStartOffset + currentByteIndex}");
+ }
+
+ // A non-zero value is color data that should be just applied directly.
+ frameData[lineIndex * frameInfo.Width + pixelPositionOnLine] = value;
+ pixelPositionOnLine++;
+ currentByteIndex++;
+ }
+ }
+
+ dataOffset += lineDataLength;
+ }
+ }
+}
diff --git a/src/MapEditorLibrary/CCEngine/Theater.cs b/src/MapEditorLibrary/CCEngine/Theater.cs
new file mode 100644
index 000000000..6cad19e3a
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/Theater.cs
@@ -0,0 +1,210 @@
+using Rampastring.Tools;
+using MapEditorLibrary.Models;
+using MapEditorLibrary.Extensions;
+using MapEditorLibrary.Misc;
+
+namespace MapEditorLibrary.CCEngine;
+
+public class LATGround
+{
+ public LATGround(string displayName, TileSet groundTileSet, TileSet transitionTileSet, TileSet baseTileSet, IEnumerable connectToTileSetIndices)
+ {
+ DisplayName = displayName;
+ GroundTileSet = groundTileSet;
+ TransitionTileSet = transitionTileSet;
+ BaseTileSet = baseTileSet;
+
+ if (connectToTileSetIndices != null)
+ ConnectToTileSetIndices.AddRange(connectToTileSetIndices);
+ }
+
+ public string DisplayName { get; }
+ public TileSet GroundTileSet { get; }
+ public TileSet TransitionTileSet { get; }
+ public TileSet BaseTileSet { get; }
+ public List ConnectToTileSetIndices = new List();
+}
+
+public class TheaterIceTileSets
+{
+ public TheaterIceTileSets(Theater theater, IniFile theaterIni)
+ {
+ int ice1SetId = theaterIni.GetIntValue("General", "Ice1Set", -1);
+ int ice2SetId = theaterIni.GetIntValue("General", "Ice2Set", -1);
+ int ice3SetId = theaterIni.GetIntValue("General", "Ice3Set", -1);
+ int iceShoreSetId = theaterIni.GetIntValue("General", "IceShoreSet", -1);
+
+ Ice1Set = theater.TryGetTileSetById(ice1SetId);
+ Ice2Set = theater.TryGetTileSetById(ice2SetId);
+ Ice3Set = theater.TryGetTileSetById(ice3SetId);
+ IceShoreSet = theater.TryGetTileSetById(iceShoreSetId);
+ }
+
+ public TileSet Ice1Set { get; private set; }
+ public TileSet Ice2Set { get; private set; }
+ public TileSet Ice3Set { get; private set; }
+ public TileSet IceShoreSet { get; private set; }
+}
+
+public class Theater : INIDefineable
+{
+ public Theater(string name)
+ {
+ UIName = name;
+ }
+
+ public string UIName { get; }
+ public string ConfigINIPath { get; set; }
+ public List ContentMIXName { get; set; }
+ public List OptionalContentMIXName { get; set; }
+ public string TerrainPaletteName { get; set; }
+ public string UnitPaletteName { get; set; }
+ public string TiberiumPaletteName { get; set; }
+ public string FileExtension { get; set; }
+ public string FallbackTileFileExtension { get; set; }
+ public char NewTheaterBuildingLetter { get; set; }
+
+ public List RoughConnectToTileSets { get; set; }
+ public List SandConnectToTileSets { get; set; }
+ public List PaveConnectToTileSets { get; set; }
+ public List GreenConnectToTileSets { get; set; }
+
+ public TheaterIceTileSets IceTileSetInfo { get; private set; }
+
+ public List TileSets = new List();
+ public List LATGrounds = new List();
+ public TileSet RampTileSet { get; set; }
+ public TileSet BridgeTileSet { get; set; }
+ public TileSet TrainBridgeTileSet { get; set; }
+ public TileSet WoodBridgeTileSet { get; set; }
+
+ private const string REQUIRED_SECTION = "General";
+
+ public TileSet FindTileSet(string tileSetName) => TileSets.Find(ts => ts.SetName == tileSetName);
+
+ public void ReadConfigINI(string baseDirectoryPath, CCFileManager ccFileManager)
+ {
+ TileSets.Clear();
+
+ IniFileEx theaterIni = IniFileEx.FromPathOrMix(ConfigINIPath, baseDirectoryPath, ccFileManager);
+
+ if (!theaterIni.SectionExists(REQUIRED_SECTION))
+ {
+ throw new FileNotFoundException("Theater config INI not found or invalid: " + ConfigINIPath);
+ }
+
+ int i;
+
+ for (i = 0; i < 10000; i++)
+ {
+ IniSection tileSetSection = theaterIni.GetSection($"TileSet{i:D4}");
+
+ if (tileSetSection == null)
+ break;
+
+ TileSet tileSet = new TileSet(i);
+ tileSet.Read(tileSetSection);
+ TileSets.Add(tileSet);
+ }
+
+ IceTileSetInfo = new TheaterIceTileSets(this, theaterIni);
+
+ i = 1;
+ while (true)
+ {
+ if (!InitLATGround(theaterIni, $"Ground{i}Tile", $"Ground{i}Lat", $"Ground{i}Base", $"Ground{i}Name", $"Ground{i}ConnectTo", null))
+ break;
+
+ i++;
+ }
+
+ // DTA
+ InitLATGround(theaterIni, "PvmntTile", "ClearToPvmntLat", null, null, null, "Pavement");
+
+ // TS terrain
+ InitLATGround(theaterIni, "RoughTile", "ClearToRoughLat", null, null, "RoughConnectTo", "Rough", RoughConnectToTileSets);
+ InitLATGround(theaterIni, "SandTile", "ClearToSandLat", null, null, "SandConnectTo", "Sand", SandConnectToTileSets);
+ InitLATGround(theaterIni, "PaveTile", "ClearToPaveLat", null, null, "PaveConnectTo", "Pavement", PaveConnectToTileSets);
+ InitLATGround(theaterIni, "GreenTile", "ClearToGreenLat", null, null, "GreenConnectTo", "Green", GreenConnectToTileSets);
+ InitLATGround(theaterIni, "CrystalTile", "ClearToCrystalLat", null, null, null, "Crystal");
+ InitLATGround(theaterIni, "BlueMoldTile", "ClearToBlueMoldLat", null, null, null, "Blue Mold");
+
+ RampTileSet = GetTileSetFromKey(theaterIni, "RampBase", false);
+ BridgeTileSet = GetTileSetFromKey(theaterIni, "BridgeSet", false);
+ TrainBridgeTileSet = GetTileSetFromKey(theaterIni, "TrainBridgeSet", true); // Unfortunately, YR terrain expansion was dumb and removed this key
+ WoodBridgeTileSet = GetTileSetFromKey(theaterIni, "WoodBridgeSet", true); // Wood bridges are optional as they do not exist in TS
+ }
+
+ private TileSet GetTileSetFromKey(IniFile theaterIni, string key, bool optional)
+ {
+ int index = theaterIni.GetIntValue("General", key, -1);
+
+ if (index < 0 || index >= TileSets.Count)
+ {
+ if (optional)
+ return null;
+
+ throw new INIConfigException($"Invalid value specified for {key}= in the theater configuration file!");
+ }
+
+ return TileSets[index];
+ }
+
+ public TileSet TryGetTileSetById(int id)
+ {
+ if (id < 0 || id >= TileSets.Count)
+ return null;
+
+ return TileSets[id];
+ }
+
+ private bool InitLATGround(IniFile theaterIni, string tileSetKey, string transitionTileSetKey, string baseTileSetKey, string nameKey, string connectToKey, string defaultName, IEnumerable connectedTileSetIndices = null)
+ {
+ int groundTileSetIndex = theaterIni.GetIntValue("General", tileSetKey, -1);
+ int transitionTileSetIndex = theaterIni.GetIntValue("General", transitionTileSetKey, -1);
+
+ int baseTileSetIndex = -1;
+ if (!string.IsNullOrEmpty(baseTileSetKey))
+ baseTileSetIndex = theaterIni.GetIntValue("General", baseTileSetKey, -1);
+
+ if (groundTileSetIndex < 0 || transitionTileSetIndex < 0)
+ return false;
+
+ if (groundTileSetIndex >= TileSets.Count || transitionTileSetIndex >= TileSets.Count)
+ return false;
+
+ string displayName = defaultName;
+ if (!string.IsNullOrEmpty(nameKey))
+ displayName = theaterIni.GetStringValue("General", nameKey, displayName);
+
+ if (displayName == null)
+ {
+ string groundTileSetName = TileSets[groundTileSetIndex].SetName;
+ displayName = groundTileSetName.Substring(0, Math.Min(groundTileSetName.Length, 4));
+ }
+
+ List indices = new List();
+
+ if ((connectedTileSetIndices == null || !connectedTileSetIndices.Any()) && !string.IsNullOrEmpty(connectToKey))
+ connectedTileSetIndices = theaterIni.GetStringValue("General", connectToKey, string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries);
+
+ if (connectedTileSetIndices != null)
+ {
+ foreach (string indexStr in connectedTileSetIndices)
+ {
+ int index = Conversions.IntFromString(indexStr, -1);
+
+ if (index != -1)
+ indices.Add(index);
+ }
+ }
+
+ LATGrounds.Add(new LATGround(
+ displayName,
+ TileSets[groundTileSetIndex],
+ TileSets[transitionTileSetIndex],
+ baseTileSetIndex > -1 ? TileSets[baseTileSetIndex] : TileSets[0], indices));
+
+ return true;
+ }
+}
diff --git a/src/MapEditorLibrary/CCEngine/TileData/ISubTileImage.cs b/src/MapEditorLibrary/CCEngine/TileData/ISubTileImage.cs
new file mode 100644
index 000000000..991439e97
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/TileData/ISubTileImage.cs
@@ -0,0 +1,9 @@
+namespace MapEditorLibrary.CCEngine.TileData;
+
+///
+/// Interface for a single cell of a tile; sub-tile of a full TMP.
+///
+public interface ISubTileImage
+{
+ TmpImage TmpImage { get; }
+}
diff --git a/src/MapEditorLibrary/CCEngine/TileData/ITileImage.cs b/src/MapEditorLibrary/CCEngine/TileData/ITileImage.cs
new file mode 100644
index 000000000..16cf2d75a
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/TileData/ITileImage.cs
@@ -0,0 +1,40 @@
+using MapEditorLibrary.GameMath;
+
+namespace MapEditorLibrary.CCEngine.TileData;
+
+///
+/// Interface for a full tile image (containing all sub-tiles).
+///
+public interface ITileImage
+{
+ ///
+ /// Width of the tile in cells.
+ ///
+ int Width { get; }
+
+ ///
+ /// Height of the tile in cells.
+ ///
+ int Height { get; }
+
+ ///
+ /// The index of the tile's tileset.
+ ///
+ int TileSetId { get; }
+
+ ///
+ /// The index of the tile within its tileset.
+ ///
+ int TileIndexInTileSet { get; }
+
+ ///
+ /// The unique ID of this tile within all tiles in the game.
+ ///
+ int TileID { get; }
+
+ int SubTileCount { get; }
+
+ ISubTileImage GetSubTile(int index);
+
+ Point2D? GetSubTileCoordOffset(int index);
+}
diff --git a/src/MapEditorLibrary/CCEngine/TileData/TheaterTileData.cs b/src/MapEditorLibrary/CCEngine/TileData/TheaterTileData.cs
new file mode 100644
index 000000000..d80442113
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/TileData/TheaterTileData.cs
@@ -0,0 +1,239 @@
+using MapEditorLibrary.Models;
+using Rampastring.Tools;
+using System.Globalization;
+
+namespace MapEditorLibrary.CCEngine.TileData;
+
+class OverlayFrameInformation
+{
+ private const string SHP_FILE_EXTENSION = ".SHP";
+ private const string PNG_FILE_EXTENSION = ".PNG";
+
+ public OverlayFrameInformation(Theater theater, CCFileManager fileManager, IReadOnlyList overlayTypes)
+ {
+ this.theater = theater;
+ this.fileManager = fileManager;
+
+ ReadOverlayFrameCounts(overlayTypes);
+ }
+
+ private readonly Theater theater;
+ private readonly CCFileManager fileManager;
+ private readonly Dictionary overlayFrameCounts = new Dictionary();
+
+ public int GetOverlayFrameCount(OverlayType overlayType)
+ {
+ if (overlayType == null)
+ throw new ArgumentNullException(nameof(overlayType));
+
+ if (!overlayFrameCounts.TryGetValue(overlayType.Index, out int frameCount))
+ throw new KeyNotFoundException($"Overlay frame count for {overlayType.ININame} has not been loaded.");
+
+ return frameCount;
+ }
+
+ public void ReadOverlayFrameCounts(IReadOnlyList overlayTypes)
+ {
+ if (overlayTypes == null)
+ throw new ArgumentNullException(nameof(overlayTypes));
+
+ Logger.Log("Loading overlay frame counts.");
+
+ overlayFrameCounts.Clear();
+
+ for (int i = 0; i < overlayTypes.Count; i++)
+ {
+ OverlayType overlayType = overlayTypes[i];
+
+ string imageName = GetOverlayImageName(overlayType);
+
+ byte[] pngData = fileManager.LoadFile(imageName + PNG_FILE_EXTENSION);
+ if (pngData != null)
+ {
+ overlayFrameCounts[overlayType.Index] = GetLogicalOverlayFrameCount(1, 0);
+ continue;
+ }
+
+ (string shpFileName, byte[] shpData) = LoadOverlayShpData(overlayType, imageName);
+ if (shpData == null)
+ {
+ overlayFrameCounts[overlayType.Index] = 0;
+ continue;
+ }
+
+ var shpFile = new ShpFile(shpFileName);
+ shpFile.ParseFromBuffer(shpData);
+
+ overlayFrameCounts[overlayType.Index] = GetLogicalOverlayFrameCount(shpFile);
+ }
+
+ Logger.Log("Finished loading overlay frame counts.");
+ }
+
+ private static string GetOverlayImageName(OverlayType overlayType)
+ {
+ string imageName = overlayType.ININame;
+
+ if (overlayType.ArtConfig.Image != null)
+ imageName = overlayType.ArtConfig.Image;
+ else if (overlayType.Image != null)
+ imageName = overlayType.Image;
+
+ return imageName;
+ }
+
+ private (string FileName, byte[] Data) LoadOverlayShpData(OverlayType overlayType, string imageName)
+ {
+ if (overlayType.ArtConfig.NewTheater)
+ {
+ string shpFileName = imageName + SHP_FILE_EXTENSION;
+ string newTheaterImageName = shpFileName.Substring(0, 1) + theater.NewTheaterBuildingLetter + shpFileName.Substring(2);
+ byte[] shpData = fileManager.LoadFile(newTheaterImageName);
+
+ if (shpData != null)
+ return (newTheaterImageName, shpData);
+
+ newTheaterImageName = shpFileName.Substring(0, 1) + Constants.NewTheaterGenericLetter + shpFileName.Substring(2);
+ shpData = fileManager.LoadFile(newTheaterImageName);
+ return (newTheaterImageName, shpData);
+ }
+
+ string fileExtension = overlayType.ArtConfig.Theater ? theater.FileExtension : SHP_FILE_EXTENSION;
+ string finalShpName = imageName + fileExtension;
+ return (finalShpName, fileManager.LoadFile(finalShpName));
+ }
+
+ private static int GetLogicalOverlayFrameCount(ShpFile shpFile)
+ {
+ int frameCount = shpFile.FrameCount;
+ int lastValidFrame = -1;
+
+ for (int i = 0; i < frameCount; i++)
+ {
+ ShpFrameInfo frameInfo = shpFile.GetShpFrameInfo(i);
+ if (frameInfo != null && frameInfo.DataOffset != 0)
+ lastValidFrame = i;
+ }
+
+ return GetLogicalOverlayFrameCount(frameCount, lastValidFrame);
+ }
+
+ private static int GetLogicalOverlayFrameCount(int frameCount, int lastValidFrame)
+ {
+ if (lastValidFrame == frameCount - 1)
+ return frameCount / 2;
+
+ return lastValidFrame + 1;
+ }
+}
+
+public interface ITheaterTileData
+{
+ TileImage GetTileImage(int id);
+}
+
+///
+/// Non-graphical equivalent of the TMP tile-loading portion of .
+///
+public class TheaterTileData : ITheater, ITheaterTileData
+{
+ private readonly CCFileManager fileManager;
+ private readonly List terrainTileDataList = new List();
+
+ public TheaterTileData(Theater theater, CCFileManager fileManager, Rules rules)
+ {
+ Theater = theater ?? throw new ArgumentNullException(nameof(theater));
+ this.fileManager = fileManager ?? throw new ArgumentNullException(nameof(fileManager));
+
+ ReadTileData();
+ overlayFrameInformation = new OverlayFrameInformation(theater, fileManager, rules.OverlayTypes);
+ }
+
+ public Theater Theater { get; }
+
+ public int TileCount => terrainTileDataList.Count;
+
+ public TileImage GetTileImage(int id) => terrainTileDataList[id][0];
+
+ public ITileImage GetTile(int id) => GetTileImage(id);
+
+ public int GetTileSetId(int uniqueTileIndex) => GetTileImage(uniqueTileIndex).TileSetId;
+
+ public int GetOverlayFrameCount(OverlayType overlayType) => overlayFrameInformation.GetOverlayFrameCount(overlayType);
+
+
+ private readonly OverlayFrameInformation overlayFrameInformation;
+
+
+ private void ReadTileData()
+ {
+ Logger.Log("Loading tile data.");
+
+ int currentTileIndex = 0; // Used for setting the starting tile ID of a tileset
+
+ for (int tsId = 0; tsId < Theater.TileSets.Count; tsId++)
+ {
+ TileSet tileSet = Theater.TileSets[tsId];
+ tileSet.StartTileIndex = currentTileIndex;
+ tileSet.LoadedTileCount = 0;
+
+ for (int i = 0; i < tileSet.TilesInSet; i++)
+ {
+ var tileImages = new List();
+
+ // Handle graphics variation (clear00.tem, clear00a.tem, clear00b.tem etc.).
+ // Even though this class does not create textures, variations can still carry
+ // distinct tile metadata, so we parse them just like TheaterGraphics does.
+ for (int v = 0; v < 'g' - 'a'; v++)
+ {
+ string baseName = tileSet.FileName + (i + 1).ToString("D2", CultureInfo.InvariantCulture);
+
+ if (v > 0)
+ baseName += (char)('a' + (v - 1));
+
+ string fileName = baseName + Theater.FileExtension;
+ byte[] data = fileManager.LoadFile(fileName);
+
+ if (data == null && !string.IsNullOrWhiteSpace(Theater.FallbackTileFileExtension))
+ {
+ // Support for the FA2 NEWURBAN hack. FA2 Marble.mix does not contain Marble
+ // Madness graphics for NEWURBAN, only URBAN. To allow Marble Madness to work
+ // in NEWURBAN, FA2 also loads .urb files for NEWURBAN.
+ fileName = baseName + Theater.FallbackTileFileExtension;
+ data = fileManager.LoadFile(fileName);
+ }
+
+ if (data == null)
+ {
+ if (v == 0)
+ {
+ tileImages.Add(new UntexturedTileImage(0, 0, tsId, i, currentTileIndex, Array.Empty()));
+ break;
+ }
+
+ break;
+ }
+
+ var tmpFile = new TmpFile(fileName);
+ tmpFile.ParseFromBuffer(data);
+
+ var tmpImages = new List();
+ for (int img = 0; img < tmpFile.ImageCount; img++)
+ {
+ TmpImage tmpImage = tmpFile.GetImage(img);
+ tmpImage?.FreeImageData();
+ tmpImages.Add(tmpImage);
+ }
+
+ tileImages.Add(new UntexturedTileImage(tmpFile.CellsX, tmpFile.CellsY, tsId, i, currentTileIndex, tmpImages.ToArray()));
+ }
+
+ tileSet.LoadedTileCount++;
+ currentTileIndex++;
+ terrainTileDataList.Add(tileImages.ToArray());
+ }
+ }
+
+ Logger.Log("Finished loading tile data.");
+ }
+}
diff --git a/src/MapEditorLibrary/CCEngine/TileData/TileImage.cs b/src/MapEditorLibrary/CCEngine/TileData/TileImage.cs
new file mode 100644
index 000000000..2ee51570b
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/TileData/TileImage.cs
@@ -0,0 +1,244 @@
+using MapEditorLibrary.GameMath;
+
+namespace MapEditorLibrary.CCEngine.TileData;
+
+///
+/// Base class for a full TMP tile, composed of one or more sub-tiles.
+///
+public abstract class TileImage : ITileImage
+{
+ protected TileImage(int width, int height, int tileSetId, int tileIndex, int tileId)
+ {
+ Width = width;
+ Height = height;
+ TileSetId = tileSetId;
+ TileIndexInTileSet = tileIndex;
+ TileID = tileId;
+ }
+
+ ///
+ /// Width of the tile in cells.
+ ///
+ public int Width { get; }
+
+ ///
+ /// Height of the tile in cells.
+ ///
+ public int Height { get; }
+
+ ///
+ /// The index of the tile set.
+ ///
+ public int TileSetId { get; set; }
+
+ ///
+ /// The index of the tile within its tileset.
+ ///
+ public int TileIndexInTileSet { get; set; }
+
+ ///
+ /// The unique ID of this tile within all tiles in the game.
+ ///
+ public int TileID { get; set; }
+
+ public abstract ISubTileImage GetSubTile(int index);
+
+ public Point2D? GetSubTileCoordOffset(int index)
+ {
+ if (GetSubTile(index) == null)
+ return null;
+
+ int x = index % Width;
+ int y = index / Width;
+ return new Point2D(x, y);
+ }
+
+ public abstract int SubTileCount { get; }
+
+ ///
+ /// Checks if a condition is true for any valid sub-tile.
+ ///
+ public bool CheckForAnyValidSubTile(Func condition)
+ {
+ for (int i = 0; i < SubTileCount; i++)
+ {
+ ISubTileImage image = GetSubTile(i);
+
+ if (image?.TmpImage == null)
+ continue;
+
+ if (condition(image))
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Performs an action for all valid sub-tiles of the tile image.
+ ///
+ /// The action to perform. First parameter is the sub-tile, second parameter its offset within the tile, and the third parameter is the sub-tile's index.
+ public void DoForValidSubTiles(Action action)
+ {
+ for (int i = 0; i < SubTileCount; i++)
+ {
+ ISubTileImage image = GetSubTile(i);
+
+ if (image == null)
+ continue;
+
+ int cx = i % Width;
+ int cy = i / Width;
+
+ action(image, new Point2D(cx, cy), i);
+ }
+ }
+
+ public bool Flat => !CheckForAnyValidSubTile(subTile => subTile.TmpImage.Height > 0);
+
+ ///
+ /// Calculates and returns the width of this full tile image.
+ ///
+ public int GetWidth(out int outMinX)
+ {
+ outMinX = 0;
+
+ if (SubTileCount == 0)
+ return 0;
+
+ int maxX = int.MinValue;
+ int minX = int.MaxValue;
+
+ for (int i = 0; i < SubTileCount; i++)
+ {
+ var tmpData = GetSubTile(i)?.TmpImage;
+ if (tmpData == null)
+ continue;
+
+ if (tmpData.X < minX)
+ minX = tmpData.X;
+
+ int cellRightXCoordinate = tmpData.X + Constants.CellSizeX;
+ if (cellRightXCoordinate > maxX)
+ maxX = cellRightXCoordinate;
+
+ if (tmpData.HasExtraData())
+ {
+ int extraRightXCoordinate = tmpData.X + tmpData.XExtra + (int)tmpData.ExtraWidth;
+ if (extraRightXCoordinate > maxX)
+ maxX = extraRightXCoordinate;
+ }
+ }
+
+ if (minX == int.MaxValue)
+ return 0;
+
+ outMinX = minX;
+ return maxX - minX;
+ }
+
+ ///
+ /// Calculates and returns the height of this full tile image.
+ ///
+ public int GetHeight()
+ {
+ if (SubTileCount == 0)
+ return 0;
+
+ int top = int.MaxValue;
+ int bottom = int.MinValue;
+
+ for (int i = 0; i < SubTileCount; i++)
+ {
+ var tmpData = GetSubTile(i)?.TmpImage;
+ if (tmpData == null)
+ continue;
+
+ int heightOffset = Constants.CellHeight * tmpData.Height;
+
+ int cellTop = tmpData.Y - heightOffset;
+ int cellBottom = cellTop + Constants.CellSizeY;
+
+ if (cellTop < top)
+ top = cellTop;
+
+ if (cellBottom > bottom)
+ bottom = cellBottom;
+
+ if (tmpData.HasExtraData())
+ {
+ int extraCellTop = tmpData.YExtra - heightOffset;
+ int extraCellBottom = extraCellTop + (int)tmpData.ExtraHeight;
+
+ if (extraCellTop < top)
+ top = extraCellTop;
+
+ if (extraCellBottom > bottom)
+ bottom = extraCellBottom;
+ }
+ }
+
+ if (top == int.MaxValue)
+ return 0;
+
+ return bottom - top;
+ }
+
+ public int GetYOffset()
+ {
+ int height = GetHeight();
+
+ int yOffset = 0;
+
+ int maxTopCoord = int.MaxValue;
+ int maxBottomCoord = int.MinValue;
+
+ for (int i = 0; i < SubTileCount; i++)
+ {
+ var tmpData = GetSubTile(i)?.TmpImage;
+ if (tmpData == null)
+ continue;
+
+ int heightOffset = Constants.CellHeight * tmpData.Height;
+ int cellTopCoord = tmpData.Y - heightOffset;
+ int cellBottomCoord = tmpData.Y + Constants.CellSizeY - heightOffset;
+
+ if (cellTopCoord < maxTopCoord)
+ maxTopCoord = cellTopCoord;
+
+ if (cellBottomCoord > maxBottomCoord)
+ maxBottomCoord = cellBottomCoord;
+ }
+
+ for (int i = 0; i < SubTileCount; i++)
+ {
+ var tmpData = GetSubTile(i)?.TmpImage;
+ if (tmpData == null)
+ continue;
+
+ if (tmpData.HasExtraData())
+ {
+ int heightOffset = Constants.CellHeight * tmpData.Height;
+
+ int extraTopCoord = tmpData.YExtra - heightOffset;
+ int extraBottomCoord = tmpData.YExtra + (int)tmpData.ExtraHeight - heightOffset;
+
+ if (extraTopCoord < maxTopCoord)
+ maxTopCoord = extraTopCoord;
+
+ if (extraBottomCoord > maxBottomCoord)
+ maxBottomCoord = extraBottomCoord;
+ }
+ }
+
+ if (maxTopCoord == int.MaxValue)
+ return 0;
+
+ if (maxTopCoord < 0)
+ yOffset = -maxTopCoord;
+ else if (maxBottomCoord > height)
+ yOffset = -(maxBottomCoord - height);
+
+ return yOffset;
+ }
+}
diff --git a/src/MapEditorLibrary/CCEngine/TileData/UntexturedSubTileImage.cs b/src/MapEditorLibrary/CCEngine/TileData/UntexturedSubTileImage.cs
new file mode 100644
index 000000000..d3716d7af
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/TileData/UntexturedSubTileImage.cs
@@ -0,0 +1,14 @@
+namespace MapEditorLibrary.CCEngine.TileData;
+
+///
+/// A TMP sub-tile that carries metadata without a MonoGame texture.
+///
+public class UntexturedSubTileImage : ISubTileImage
+{
+ public UntexturedSubTileImage(TmpImage tmpImage)
+ {
+ TmpImage = tmpImage;
+ }
+
+ public TmpImage TmpImage { get; }
+}
diff --git a/src/MapEditorLibrary/CCEngine/TileData/UntexturedTileImage.cs b/src/MapEditorLibrary/CCEngine/TileData/UntexturedTileImage.cs
new file mode 100644
index 000000000..dae7d4b96
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/TileData/UntexturedTileImage.cs
@@ -0,0 +1,20 @@
+namespace MapEditorLibrary.CCEngine.TileData;
+
+///
+/// Contains metadata for a single full TMP (all sub-tiles / all cells) without texture data.
+///
+public class UntexturedTileImage : TileImage
+{
+ public UntexturedTileImage(int width, int height, int tileSetId, int tileIndex, int tileId, TmpImage[] tmpImages)
+ : base(width, height, tileSetId, tileIndex, tileId)
+ {
+ tmpImages ??= Array.Empty();
+ TMPImages = Array.ConvertAll(tmpImages, tmpImage => tmpImage == null ? null : new UntexturedSubTileImage(tmpImage));
+ }
+
+ public override ISubTileImage GetSubTile(int index) => TMPImages[index];
+
+ public override int SubTileCount => TMPImages.Length;
+
+ public UntexturedSubTileImage[] TMPImages { get; set; }
+}
diff --git a/src/MapEditorLibrary/CCEngine/TileSet.cs b/src/MapEditorLibrary/CCEngine/TileSet.cs
new file mode 100644
index 000000000..493192dad
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/TileSet.cs
@@ -0,0 +1,95 @@
+using MapEditorLibrary.Misc;
+using MapEditorLibrary.Models;
+using Microsoft.Xna.Framework;
+using Rampastring.Tools;
+using Rampastring.XNAUI;
+
+namespace MapEditorLibrary.CCEngine;
+
+public class TileSet : INIDefineable
+{
+ public TileSet(int index)
+ {
+ Index = index;
+ SortID = index.ToString();
+ }
+
+ public int Index { get; }
+ public string SortID { get; set; }
+ public string SetName { get; set; }
+
+ [INI(false)]
+ public string TranslatedName { get; set; }
+ public string FileName { get; set; }
+ public int TilesInSet { get; set; }
+ public bool Morphable { get; set; }
+ public int MarbleMadness { get; set; } = -1;
+ public int NonMarbleMadness { get; set; } = -1;
+ public bool AllowTiberium { get; set; }
+ public bool AllowToPlace { get; set; } = true;
+ public bool Only1x1 { get; set; }
+ public Color? Color { get; set; }
+
+ ///
+ /// The unique tile ID of the first tile of this tileset.
+ ///
+ public int StartTileIndex { get; set; }
+
+ ///
+ /// The actual amount of tiles successfully loaded for this tile set.
+ ///
+ public int LoadedTileCount { get; set; }
+
+ ///
+ /// Checks and returns a value that determines whether a tile with a specific
+ /// index exists within this tile set.
+ ///
+ /// The index of the tile.
+ public bool ContainsTile(int tileIndex) => tileIndex >= StartTileIndex && tileIndex < StartTileIndex + LoadedTileCount;
+
+ public Dictionary TiberiumGraphicsOverrides { get; set; }
+ public List<(string tiberiumTypeName, string graphicalOverlayName)> ParsedTiberiumGraphicsOverrides { get; set; }
+
+ private static string[] only1x1TileSets = new string[] { "cliffs", "rivers", "shores", "dirt road" };
+
+ public void Read(IniSection iniSection)
+ {
+ ReadPropertiesFromIniSection(iniSection);
+ TranslatedName = Translate(this, SetName, SetName);
+
+ foreach (string namepart in only1x1TileSets)
+ {
+ if (SetName != null && SetName.ToLowerInvariant().Contains(namepart))
+ Only1x1 = true;
+ }
+
+ const string colorKeyName = "EditorColor";
+ if (iniSection.KeyExists(colorKeyName))
+ Color = iniSection.GetColorValue(colorKeyName, UISettings.ActiveSettings.AltColor);
+
+ // Support for DTA TiberiumOverlays graphics override feature
+ // TiberiumOverlays=Riparius:TIB1S_01,Vinifera:TIB2S_01,Ore:ORESNO01,Gems:GEMSRA01,GreenGems:GEMSRA01,BlueGems:GEMSRA01
+ const string tiberiumOverlaysKeyName = "TiberiumOverlays";
+ if (iniSection.KeyExists(tiberiumOverlaysKeyName))
+ {
+ ParsedTiberiumGraphicsOverrides = new List<(string tiberiumTypeName, string graphicalOverlayName)>();
+ string value = iniSection.GetStringValue(tiberiumOverlaysKeyName, null);
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ string[] values = value.Split(',', StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (string overrideValue in values)
+ {
+ string[] parts = overrideValue.Split(':');
+
+ if (parts.Length != 2)
+ {
+ throw new INIConfigException($"Failed to parse TiberiumOverlays= of TileSet {SetName} (#{Index}).");
+ }
+
+ ParsedTiberiumGraphicsOverrides.Add((parts[0], parts[1]));
+ }
+ }
+ }
+ }
+}
diff --git a/src/MapEditorLibrary/CCEngine/TmpFile.cs b/src/MapEditorLibrary/CCEngine/TmpFile.cs
new file mode 100644
index 000000000..72d4a9b28
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/TmpFile.cs
@@ -0,0 +1,215 @@
+namespace MapEditorLibrary.CCEngine;
+
+///
+/// A Tiberian Sun TMP file.
+/// Represents a collection of sub-tiles that make up a full tile.
+///
+public class TmpFile
+{
+ public TmpFile(string fileName)
+ {
+ this.fileName = fileName;
+ }
+
+ private readonly string fileName;
+
+ private TmpFileHeader tmpFileHeader;
+ private List tmpImages = new List();
+
+ public int ImageCount => tmpImages.Count;
+ public TmpImage GetImage(int id) => tmpImages[id];
+
+ public int CellsX => tmpFileHeader.Width;
+
+ public int CellsY => tmpFileHeader.Height;
+
+ public void ParseFromFile(string filePath)
+ {
+ using (FileStream stream = File.OpenRead(filePath))
+ {
+ Parse(stream);
+ }
+ }
+
+ public void Parse(Stream stream)
+ {
+ byte[] buffer = new byte[stream.Length];
+ stream.Position = 0;
+ stream.Read(buffer, 0, buffer.Length);
+ ParseFromBuffer(buffer);
+ }
+
+ public void ParseFromBuffer(byte[] buffer)
+ {
+ tmpFileHeader = new TmpFileHeader(buffer);
+ int tileCount = tmpFileHeader.Width * tmpFileHeader.Height;
+
+ List tmpHeaderOffsets = new List();
+
+ for (int i = 0; i < tileCount; i++)
+ {
+ int offset = BitConverter.ToInt32(buffer, TmpFileHeader.SIZE + i * 4);
+ tmpHeaderOffsets.Add(offset);
+ }
+
+ using (var memoryStream = new MemoryStream(buffer))
+ {
+ for (int i = 0; i < tileCount; i++)
+ {
+ if (tmpHeaderOffsets[i] == 0)
+ {
+ tmpImages.Add(null);
+ }
+ else
+ {
+ memoryStream.Position = tmpHeaderOffsets[i];
+ TmpImage tmpImage = new TmpImage(memoryStream, fileName);
+ tmpImages.Add(tmpImage);
+ }
+ }
+ }
+ }
+}
+
+///
+/// A TMP file header.
+///
+struct TmpFileHeader
+{
+ public const int SIZE = 16;
+
+ public TmpFileHeader(byte[] buffer)
+ {
+ if (buffer.Length < SIZE)
+ throw new ArgumentException("buffer is not long enough");
+
+ Width = BitConverter.ToInt32(buffer, 0);
+ Height = BitConverter.ToInt32(buffer, 4);
+ TileWidth = BitConverter.ToInt32(buffer, 8);
+ TileHeight = BitConverter.ToInt32(buffer, 12);
+ }
+
+ public int Width { get; private set; }
+ public int Height { get; private set; }
+ public int TileWidth { get; private set; }
+ public int TileHeight { get; private set; }
+}
+
+///
+/// A single TMP image (representing a single cell).
+///
+public class TmpImage
+{
+ public const int IMAGE_HEADER_SIZE = 48;
+
+ public TmpImage(Stream stream, string fileName)
+ {
+ long expectedLength = stream.Position + IMAGE_HEADER_SIZE + Constants.TileColorBufferSize;
+ if (stream.Length < expectedLength)
+ {
+ throw new ArgumentException($"TMP file buffer ran out unexpectedly while reading ${fileName}: " +
+ $"expected length of at least {expectedLength}, actual length: {stream.Length}");
+ }
+
+ X = ReadIntFromStream(stream);
+ Y = ReadIntFromStream(stream);
+ ExtraDataOffset = ReadUIntFromStream(stream);
+ ZDataOffset = ReadUIntFromStream(stream);
+ ExtraZDataOffset = ReadUIntFromStream(stream);
+ XExtra = ReadIntFromStream(stream);
+ YExtra = ReadIntFromStream(stream);
+ ExtraWidth = ReadUIntFromStream(stream);
+ ExtraHeight = ReadUIntFromStream(stream);
+ stream.Read(buffer, 0, 4);
+ // The image flags of WW tiles contain
+ // trash / uninitialized memory which we have to clear
+ ImageFlags = (TmpImageFlags)(BitConverter.ToUInt32(buffer, 0));
+ stream.Read(buffer, 0, 3);
+ Height = buffer[0];
+ TerrainType = buffer[1];
+ RampType = (RampType)buffer[2];
+ RadarLeftColor = ReadRGBColorFromStream(stream);
+ RadarRightColor = ReadRGBColorFromStream(stream);
+ stream.Read(buffer, 0, 3); // Discard 3 more bytes of WW trash data / uninitialized memory
+ stream.Read(ColorData, 0, Constants.TileColorBufferSize);
+
+ if ((ImageFlags & TmpImageFlags.HasZData) == TmpImageFlags.HasZData)
+ {
+ ZData = new byte[Constants.TileColorBufferSize];
+ stream.Read(ZData, 0, ZData.Length);
+ }
+
+ if ((ImageFlags & TmpImageFlags.HasExtraData) == TmpImageFlags.HasExtraData)
+ {
+ ExtraGraphicsColorData = new byte[ExtraWidth * ExtraHeight];
+ stream.Read(ExtraGraphicsColorData, 0, ExtraGraphicsColorData.Length);
+
+ if ((ImageFlags & TmpImageFlags.HasZData) == TmpImageFlags.HasZData && ExtraZDataOffset > 0)
+ {
+ ExtraGraphicsZData = new byte[ExtraWidth * ExtraHeight];
+ stream.Read(ExtraGraphicsZData, 0, ExtraGraphicsZData.Length);
+ }
+ }
+ }
+
+ public void FreeImageData()
+ {
+ ColorData = null;
+ ZData = null;
+ ExtraGraphicsColorData = null;
+ ExtraGraphicsZData = null;
+ }
+
+ public bool HasExtraData() => (ImageFlags & TmpImageFlags.HasExtraData) == TmpImageFlags.HasExtraData;
+
+ private int ReadIntFromStream(Stream stream)
+ {
+ stream.Read(buffer, 0, 4);
+ return BitConverter.ToInt32(buffer, 0);
+ }
+
+ private uint ReadUIntFromStream(Stream stream)
+ {
+ stream.Read(buffer, 0, 4);
+ return BitConverter.ToUInt32(buffer, 0);
+ }
+
+ private RGBColor ReadRGBColorFromStream(Stream stream)
+ {
+ stream.Read(buffer, 0, 3);
+ return new RGBColor(buffer, 0, 0);
+ }
+
+ byte[] buffer = new byte[4];
+
+ public int X { get; private set; }
+ public int Y { get; private set; }
+ public uint ExtraDataOffset { get; private set; }
+ public uint ZDataOffset { get; private set; }
+ public uint ExtraZDataOffset { get; private set; }
+ public int XExtra { get; private set; }
+ public int YExtra { get; private set; }
+ public uint ExtraWidth { get; private set; }
+ public uint ExtraHeight { get; private set; }
+ public TmpImageFlags ImageFlags { get; private set; }
+ public byte Height { get; private set; }
+ public byte TerrainType { get; private set; }
+ public RampType RampType { get; private set; }
+
+ public RGBColor RadarLeftColor { get; set; }
+ public RGBColor RadarRightColor { get; set; }
+
+ public byte[] ColorData = new byte[Constants.TileColorBufferSize];
+ public byte[] ZData = Array.Empty();
+ public byte[] ExtraGraphicsColorData = Array.Empty();
+ public byte[] ExtraGraphicsZData = Array.Empty();
+}
+
+[Flags]
+public enum TmpImageFlags : uint
+{
+ None = 0,
+ HasExtraData = 0x01,
+ HasZData = 0x02,
+ HasDamagedData = 0x04
+}
diff --git a/src/MapEditorLibrary/CCEngine/TriggerActionType.cs b/src/MapEditorLibrary/CCEngine/TriggerActionType.cs
new file mode 100644
index 000000000..7dc7d5be8
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/TriggerActionType.cs
@@ -0,0 +1,78 @@
+using MapEditorLibrary.Models.Enums;
+using Rampastring.Tools;
+using System.Globalization;
+
+namespace MapEditorLibrary.CCEngine;
+
+public class TriggerActionParam
+{
+ public TriggerActionParam(TriggerParamType triggerParamType, string nameOverride, List presetOptions = null)
+ {
+ TriggerParamType = triggerParamType;
+ NameOverride = nameOverride;
+ PresetOptions = presetOptions;
+ }
+
+ public TriggerParamType TriggerParamType { get; }
+ public string NameOverride { get; }
+ public List PresetOptions { get; }
+
+ public bool HasPresetOptions() => PresetOptions != null && PresetOptions.Count > 0;
+}
+
+public class TriggerActionType
+{
+ public const int MAX_PARAM_COUNT = 7;
+
+ public TriggerActionType(int id)
+ {
+ ID = id;
+ }
+
+
+ public int ID { get; set; }
+
+ public string Name { get; set; }
+ public string Description { get; set; }
+ public TriggerActionParam[] Parameters { get; } = new TriggerActionParam[MAX_PARAM_COUNT];
+
+ public void ReadPropertiesFromIniSection(IniSection iniSection)
+ {
+ ID = iniSection.GetIntValue("IDOverride", ID);
+ string untranslatedName = iniSection.GetStringValue(nameof(Name), string.Empty);
+ Name = Translate(this, untranslatedName + ".Name", untranslatedName);
+ Description = Translate(this, untranslatedName + ".Description", iniSection.GetStringValue(nameof(Description), string.Empty));
+
+ for (int i = 0; i < Parameters.Length; i++)
+ {
+ string key = $"P{i + 1}Type";
+ string nameOverrideKey = $"P{i + 1}Name";
+ string presetOptionsKey = $"P{i + 1}PresetOptions";
+
+ if (!iniSection.KeyExists(key))
+ {
+ Parameters[i] = new TriggerActionParam(TriggerParamType.Unused, null);
+ continue;
+ }
+
+ var triggerParamType = (TriggerParamType)Enum.Parse(typeof(TriggerParamType), iniSection.GetStringValue(key, string.Empty));
+ string nameOverride = Translate(this, untranslatedName + ".Parameter" + i.ToString(CultureInfo.InvariantCulture) + ".NameOverride", iniSection.GetStringValue(nameOverrideKey, null));
+ if (triggerParamType == TriggerParamType.WaypointZZ && string.IsNullOrWhiteSpace(nameOverride))
+ nameOverride = Translate(nameof(TriggerParamType) + "." + nameof(TriggerParamType.Waypoint), "Waypoint");
+
+ List presetOptions = null;
+ string presetOptionsString = iniSection.GetStringValue(presetOptionsKey, null);
+ if (!string.IsNullOrWhiteSpace(presetOptionsString))
+ {
+ presetOptions = new List(presetOptionsString.Split(new char[] {','}, StringSplitOptions.RemoveEmptyEntries));
+
+ for (int j = 0; j < presetOptions.Count; j++)
+ {
+ presetOptions[j] = Translate(this, untranslatedName + ".Parameter" + i.ToString(CultureInfo.InvariantCulture) + ".PresentOption" + j.ToString(CultureInfo.InvariantCulture), presetOptions[j]);
+ }
+ }
+
+ Parameters[i] = new TriggerActionParam(triggerParamType, nameOverride, presetOptions);
+ }
+ }
+}
diff --git a/src/MapEditorLibrary/CCEngine/TriggerEventType.cs b/src/MapEditorLibrary/CCEngine/TriggerEventType.cs
new file mode 100644
index 000000000..87d84cd46
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/TriggerEventType.cs
@@ -0,0 +1,95 @@
+using MapEditorLibrary.Models.Enums;
+using Rampastring.Tools;
+using System.Globalization;
+
+namespace MapEditorLibrary.CCEngine;
+
+public class TriggerEventParam
+{
+ public TriggerEventParam(TriggerParamType triggerParamType, string nameOverride, List presetOptions = null)
+ {
+ TriggerParamType = triggerParamType;
+ NameOverride = nameOverride;
+ PresetOptions = presetOptions;
+ }
+
+ public TriggerParamType TriggerParamType { get; }
+ public string NameOverride { get; }
+ public List PresetOptions { get; }
+}
+
+public class TriggerEventType
+{
+ public const int DEF_PARAM_COUNT = 2;
+ public const int MAX_PARAM_COUNT = 4;
+
+ public TriggerEventType(int id)
+ {
+ ID = id;
+ }
+
+ public int ID { get; set; }
+
+ public string Name { get; set; }
+ public string Description { get; set; }
+ public TriggerEventParam[] Parameters { get; } = new TriggerEventParam[MAX_PARAM_COUNT];
+ public bool Available { get; set; } = true;
+
+ public int AdditionalParams
+ {
+ get
+ {
+ int additionalParams = 0;
+
+ for (int i = DEF_PARAM_COUNT; i < MAX_PARAM_COUNT; i++)
+ {
+ var param = Parameters[i];
+ if (param.TriggerParamType != TriggerParamType.Unused)
+ additionalParams++;
+ }
+
+ return additionalParams;
+ }
+ }
+
+ public void ReadPropertiesFromIniSection(IniSection iniSection)
+ {
+ ID = iniSection.GetIntValue("IDOverride", ID);
+ string untranslatedName = iniSection.GetStringValue(nameof(Name), string.Empty);
+ Name = Translate(this, untranslatedName + ".Name", untranslatedName);
+ Description = Translate(this, untranslatedName + ".Description", iniSection.GetStringValue(nameof(Description), string.Empty));
+ Available = iniSection.GetBooleanValue(nameof(Available), true);
+
+ for (int i = 0; i < Parameters.Length; i++)
+ {
+ string key = $"P{i + 1}Type";
+ string nameOverrideKey = $"P{i + 1}Name";
+ string presetOptionsKey = $"P{i + 1}PresetOptions";
+
+ if (!iniSection.KeyExists(key))
+ {
+ Parameters[i] = new TriggerEventParam(TriggerParamType.Unused, null);
+ continue;
+ }
+
+ var triggerParamType = (TriggerParamType)Enum.Parse(typeof(TriggerParamType), iniSection.GetStringValue(key, string.Empty));
+ string nameOverride = Translate(this, untranslatedName + ".Parameter" + i.ToString(CultureInfo.InvariantCulture) + ".NameOverride", iniSection.GetStringValue(nameOverrideKey, null));
+ if (triggerParamType == TriggerParamType.WaypointZZ && string.IsNullOrWhiteSpace(nameOverride))
+ nameOverride = Translate(nameof(TriggerParamType) + "." + nameof(TriggerParamType.Waypoint), "Waypoint");
+
+ List presetOptions = null;
+ string presetOptionsString = iniSection.GetStringValue(presetOptionsKey, null);
+ if (!string.IsNullOrWhiteSpace(presetOptionsString))
+ {
+ presetOptions = new List(presetOptionsString.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries));
+
+ for (int j = 0; j < presetOptions.Count; j++)
+ {
+ presetOptions[j] = Translate(this, untranslatedName + ".Parameter" + i.ToString(CultureInfo.InvariantCulture) + ".PresentOption" + j.ToString(CultureInfo.InvariantCulture), presetOptions[j]);
+ }
+ }
+
+ Parameters[i] = new TriggerEventParam(triggerParamType, nameOverride, presetOptions);
+ }
+ }
+}
diff --git a/src/MapEditorLibrary/CCEngine/VplFile.cs b/src/MapEditorLibrary/CCEngine/VplFile.cs
new file mode 100644
index 000000000..3a482d625
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/VplFile.cs
@@ -0,0 +1,54 @@
+using CNCMaps.FileFormats.VirtualFileSystem;
+
+namespace MapEditorLibrary.CCEngine;
+
+///
+/// .vpl file format
+/// Based on the CNCMaps Renderer code
+/// https://github.com/zzattack/ccmaps-net
+///
+public class VplFile : VirtualFile
+{
+ public VplFile(Stream baseStream, string filename, int baseOffset, int fileSize, bool isBuffered = false)
+ : base(baseStream, filename, baseOffset, fileSize, isBuffered)
+ {
+ Parse();
+ }
+
+ public VplFile(Stream baseStream, string filename = "", bool isBuffered = true)
+ : base(baseStream, filename, isBuffered)
+ {
+ Parse();
+ }
+
+
+ public VplFile(byte[] buffer, string filename = "") : base(new MemoryStream(buffer), filename, true)
+ {
+ Parse();
+ }
+
+
+ private uint firstRemap;
+ private uint lastRemap;
+ private uint numSections;
+ private uint unknown;
+ // private Palette _palette; // unused
+ private List lookupSections = new();
+
+ private void Parse()
+ {
+ firstRemap = ReadUInt32();
+ lastRemap = ReadUInt32();
+ numSections = ReadUInt32();
+ unknown = ReadUInt32();
+ var pal = Read(768);
+ // palette = new Palette(pal, "voxels.vpl");
+ for (uint i = 0; i < numSections; i++)
+ lookupSections.Add(Read(256));
+ }
+
+ public byte GetPaletteIndex(byte page, byte color)
+ {
+ return lookupSections[page][color];
+ }
+}
\ No newline at end of file
diff --git a/src/MapEditorLibrary/CCEngine/VxlFile.cs b/src/MapEditorLibrary/CCEngine/VxlFile.cs
new file mode 100644
index 000000000..a4d0807cc
--- /dev/null
+++ b/src/MapEditorLibrary/CCEngine/VxlFile.cs
@@ -0,0 +1,661 @@
+using CNCMaps.FileFormats.VirtualFileSystem;
+using Microsoft.Xna.Framework;
+using System.Diagnostics;
+
+namespace MapEditorLibrary.CCEngine;
+
+public class VxlLoadException : Exception
+{
+ public VxlLoadException(string message) : base(message) { }
+}
+
+///
+/// .vxl file format
+/// Based on the CNCMaps Renderer code
+/// https://github.com/zzattack/ccmaps-net
+///
+
+public class VxlFile : VirtualFile
+{
+ public FileHeader Header = new();
+ public List Sections = new();
+
+ public VxlFile(Stream baseStream, string filename, int baseOffset, int fileSize, bool isBuffered = false)
+ : base(baseStream, filename, baseOffset, fileSize, isBuffered)
+ {
+ Initialize();
+ }
+
+ public VxlFile(Stream baseStream, string filename = "", bool isBuffered = true)
+ : base(baseStream, filename, isBuffered)
+ {
+ Initialize();
+ }
+
+ public VxlFile(byte[] buffer, string filename = "") : base(new MemoryStream(buffer), filename, true)
+ {
+ Initialize();
+ }
+
+
+ private void Initialize()
+ {
+ if (Length < FileHeader.Size)
+ throw new VxlLoadException(nameof(VxlFile) + " .vxl is shorter than specified in its header!");
+
+ Header.Read(this);
+ if (Header.HeaderCount == 0 || Header.TailerCount == 0 || Header.TailerCount != Header.HeaderCount)
+ throw new VxlLoadException(nameof(VxlFile) + " .vxl has no header or tailer sections, or their count doesn't match!");
+
+ // start with headers
+ for (int i = 0; i < Header.HeaderCount; ++i)
+ {
+ Sections.Add(new Section(i));
+ Sections[i].ReadHeader(this);
+ }
+
+ // then we need tailers before before bodies can be constructed
+ long bodyStart = Position;
+ Seek(Header.BodySize, SeekOrigin.Current);
+ for (int i = 0; i < Header.TailerCount; ++i)
+ Sections[i].ReadTailer(this);
+
+ for (int i = 0; i < Header.HeaderCount; ++i)
+ {
+ Seek(bodyStart, SeekOrigin.Begin);
+ Sections[i].ReadBodySpans(this);
+ }
+ }
+
+ public class FileHeader
+ {
+ public const int Size = 32;
+
+ public string FileName;
+ public uint PaletteCount;
+ public uint HeaderCount;
+ public uint TailerCount;
+ public uint BodySize;
+ public byte PaletteRemapStart;
+ public byte PaletteRemapEnd;
+ // public Palette Palette; // not actually used
+
+ public void Read(VxlFile vxlFile)
+ {
+ FileName = vxlFile.ReadCString(16);
+ PaletteCount = vxlFile.ReadUInt32();
+ HeaderCount = vxlFile.ReadUInt32();
+ TailerCount = vxlFile.ReadUInt32();
+ Debug.Assert(HeaderCount == TailerCount);
+ BodySize = vxlFile.ReadUInt32();
+ PaletteRemapStart = vxlFile.ReadByte();
+ PaletteRemapEnd = vxlFile.ReadByte();
+ var pal = vxlFile.Read(768);
+ // Palette = new Palette(pal, "voxel palette");
+ }
+
+ };
+
+ public class Voxel
+ {
+ public byte X;
+ public byte Y;
+ public byte Z;
+ public byte ColorIndex;
+ public byte NormalIndex;
+ };
+
+ public class SectionSpan
+ {
+ public byte X, Y;
+ public int StartIndex;
+ public int EndIndex;
+
+ public byte Height;
+
+ public List Voxels = new();
+
+ public int SpanLength => (EndIndex - StartIndex) + 1;
+ public void Read(VirtualFile file)
+ {
+ if (StartIndex == -1 || EndIndex == -1)
+ return;
+
+ for (byte z = 0; z < Height;)
+ {
+ z += file.ReadByte(); // skip
+ byte c = file.ReadByte(); // numvoxels
+ for (var i = 0; i < c; ++i)
+ Voxels.Add(new Voxel { X = X, Y = Y, Z = z++, ColorIndex = file.ReadByte(), NormalIndex = file.ReadByte() });
+ byte c2 = file.ReadByte(); // numvoxels, repeated
+ }
+ }
+ };
+
+ public class TransfMatrix
+ {
+ public Vector4[] V = new Vector4[3];
+
+ public void Read(VxlFile vxlFile)
+ {
+ for (var i = 0; i < 3; ++i)
+ {
+ V[i].X = vxlFile.ReadFloat();
+ V[i].Y = vxlFile.ReadFloat();
+ V[i].Z = vxlFile.ReadFloat();
+ V[i].W = vxlFile.ReadFloat();
+ }
+ }
+ };
+
+ public class Section
+ {
+ public Section(int index)
+ {
+ Index = index;
+ }
+
+ public int Index;
+ // header
+ public string Name;
+ public uint LimbNumber;
+ private uint unknown1;
+ private uint unknown2;
+
+ // body
+ public SectionSpan[,] Spans;
+
+ // tailer
+ public uint StartingSpanOffset;
+ public uint EndingSpanOffset;
+ public uint DataSpanOffset;
+ public float HvaMatrixScale;
+ public TransfMatrix TransfMatrix = new();
+ public Vector3 MinBounds;
+ public Vector3 MaxBounds;
+ public byte SizeX;
+ public byte SizeY;
+ public byte SizeZ;
+ public byte NormalsMode;
+
+ public float SpanX => MaxBounds.X - MinBounds.X;
+ public float SpanY => MaxBounds.Y - MinBounds.Y;
+ public float SpanZ => MaxBounds.Z - MinBounds.Z;
+ public float ScaleX => SpanX * 1.0f / SizeX;
+ public float ScaleY => SpanY * 1.0f / SizeY;
+ public float ScaleZ => SpanZ * 1.0f / SizeZ;
+ public Vector3 Scale => new(ScaleX, ScaleY, ScaleZ);
+
+ public void ReadHeader(VxlFile vxlFile)
+ {
+ Name = vxlFile.ReadCString(16);
+ LimbNumber = vxlFile.ReadUInt32();
+ unknown1 = vxlFile.ReadUInt32();
+ unknown2 = vxlFile.ReadUInt32();
+ }
+
+ public void ReadBodySpans(VxlFile vxlFile)
+ {
+ // need to have position at start of bodies
+ vxlFile.Seek(StartingSpanOffset, SeekOrigin.Current);
+ Spans = new SectionSpan[SizeX, SizeY];
+
+ for (byte y = 0; y < SizeY; ++y)
+ {
+ for (byte x = 0; x < SizeX; ++x)
+ {
+ var s = new SectionSpan();
+ s.StartIndex = vxlFile.ReadInt32();
+ s.Height = SizeZ;
+ s.X = x;
+ s.Y = y;
+ Spans[x, y] = s;
+ }
+ }
+
+ for (byte y = 0; y < SizeY; ++y)
+ {
+ for (byte x = 0; x < SizeX; ++x)
+ {
+ Spans[x, y].EndIndex = vxlFile.ReadInt32();
+ }
+ }
+
+ for (byte y = 0; y < SizeY; ++y)
+ {
+ for (byte x = 0; x < SizeX; ++x)
+ {
+ Spans[x, y].Read(vxlFile);
+ }
+ }
+ }
+
+ public void ReadTailer(VxlFile vxlFile)
+ {
+ StartingSpanOffset = vxlFile.ReadUInt32();
+ EndingSpanOffset = vxlFile.ReadUInt32();
+ DataSpanOffset = vxlFile.ReadUInt32();
+ HvaMatrixScale = vxlFile.ReadFloat();
+ TransfMatrix.Read(vxlFile);
+ MinBounds.X = vxlFile.ReadFloat();
+ MinBounds.Y = vxlFile.ReadFloat();
+ MinBounds.Z = vxlFile.ReadFloat();
+ MaxBounds.X = vxlFile.ReadFloat();
+ MaxBounds.Y = vxlFile.ReadFloat();
+ MaxBounds.Z = vxlFile.ReadFloat();
+
+ SizeX = vxlFile.ReadByte();
+ SizeY = vxlFile.ReadByte();
+ SizeZ = vxlFile.ReadByte();
+ NormalsMode = vxlFile.ReadByte();
+ }
+
+ public Vector3[] GetNormals()
+ {
+ switch (NormalsMode)
+ {
+ case 1:
+ return Normals1;
+ case 2:
+ return Normals2;
+ case 3:
+ return Normals3;
+ case 4:
+ return Normals4;
+ default:
+ throw new ArgumentException("Voxel normals index ranges from 1 to 4");
+ }
+ }
+
+ public Voxel GetVoxel(uint x, uint y, uint z)
+ {
+ if (x < Spans.GetLength(0) && y < Spans.GetLength(1) && z < Spans[x, y].Voxels.Count)
+ return Spans[x, y].Voxels[(int)z];
+ return null;
+ }
+
+ public Vector3 GetNormal(byte p)
+ {
+ var normals = GetNormals();
+ return normals[p < normals.Length ? p : normals.Length - 1];
+ }
+ }
+
+ #region Normal Tables
+
+ public static readonly Vector3[] Normals1 = new Vector3[] {
+ new Vector3(0.54946297f, -0.000183f, -0.835518f),
+ new Vector3(0.00014400001f, 0.54940403f, -0.83555698f),
+ new Vector3(-0.54940403f, -0.000068000001f, -0.83555698f),
+ new Vector3(0.000106f, -0.54946297f, -0.835518f),
+ new Vector3(0.94900799f, 0.00031599999f, -0.31525001f),
+ new Vector3(-0.000186f, 0.94899702f, -0.31528401f),
+ new Vector3(-0.94899702f, 0.00031800001f, -0.31528401f),
+ new Vector3(-0.000447f, -0.94900799f, -0.31525001f),
+ new Vector3(0.95084399f, -0.000279f, 0.30967101f),
+ new Vector3(0.000202f, 0.95084798f, 0.30965701f),
+ new Vector3(-0.95084798f, -0.000070000002f, 0.30965701f),
+ new Vector3(0.000147f, -0.95084399f, 0.30967101f),
+ new Vector3(0.55237001f, -0.000011f, 0.83359897f),
+ new Vector3(0.000019999999f, 0.55238003f, 0.833592f),
+ new Vector3(-0.55238003f, 0.000057000001f, 0.83359301f),
+ new Vector3(-0.000066000001f, -0.55237001f, 0.83359897f),
+ };
+
+ public static readonly Vector3[] Normals2 = new Vector3[] {
+ new Vector3(0.67121398f, 0.19849201f, -0.714194f),
+ new Vector3(0.26964301f, 0.58439398f, -0.76536f),
+ new Vector3(-0.040546f, 0.096988f, -0.99445897f),
+ new Vector3(-0.57242799f, -0.091913998f, -0.81478697f),
+ new Vector3(-0.17140099f, -0.57270998f, -0.80163902f),
+ new Vector3(0.36255699f, -0.30299899f, -0.88133103f),
+ new Vector3(0.81034702f, -0.34897199f, -0.470698f),
+ new Vector3(0.103962f, 0.93867201f, -0.328767f),
+ new Vector3(-0.324047f, 0.58766901f, -0.74137598f),
+ new Vector3(-0.80086499f, 0.34046099f, -0.49264699f),
+ new Vector3(-0.66549802f, -0.59014702f, -0.45698899f),
+ new Vector3(0.314767f, -0.803002f, -0.506073f),
+ new Vector3(0.97262901f, 0.151076f, -0.17655f),
+ new Vector3(0.680291f, 0.68423599f, -0.26272699f),
+ new Vector3(-0.52007902f, 0.82777703f, -0.210483f),
+ new Vector3(-0.96164399f, -0.179001f, -0.207847f),
+ new Vector3(-0.262714f, -0.937451f, -0.22840101f),
+ new Vector3(0.219707f, -0.97130102f, 0.091124997f),
+ new Vector3(0.92380798f, -0.229975f, 0.30608699f),
+ new Vector3(-0.082488999f, 0.97065997f, 0.225866f),
+ new Vector3(-0.59179801f, 0.69678998f, 0.40528899f),
+ new Vector3(-0.92529601f, 0.36660099f, 0.097111002f),
+ new Vector3(-0.705051f, -0.68777502f, 0.172828f),
+ new Vector3(0.7324f, -0.68036699f, -0.026304999f),
+ new Vector3(0.85516202f, 0.37458199f, 0.358311f),
+ new Vector3(0.47300601f, 0.83648002f, 0.276705f),
+ new Vector3(-0.097617f, 0.65411198f, 0.750072f),
+ new Vector3(-0.90412402f, -0.153725f, 0.39865801f),
+ new Vector3(-0.211916f, -0.85808998f, 0.46773201f),
+ new Vector3(0.50022697f, -0.67440802f, 0.543091f),
+ new Vector3(0.584539f, -0.110249f, 0.80384099f),
+ new Vector3(0.43737301f, 0.45464399f, 0.77588898f),
+ new Vector3(-0.042440999f, 0.083318003f, 0.995619f),
+ new Vector3(-0.59625101f, 0.22013199f, 0.77202803f),
+ new Vector3(-0.506455f, -0.39697701f, 0.76544899f),
+ new Vector3(0.070569001f, -0.47847399f, 0.87526202f),
+ };
+
+ public static readonly Vector3[] Normals3 = new Vector3[] {
+ new Vector3(0.45651099f, -0.073968001f, -0.88663799f),
+ new Vector3(0.50769401f, 0.38511699f, -0.77067f),
+ new Vector3(0.095431998f, 0.22666401f, -0.96928602f),
+ new Vector3(-0.35876599f, 0.54318798f, -0.75910097f),
+ new Vector3(-0.361276f, 0.13299499f, -0.92292601f),
+ new Vector3(-0.48311701f, -0.32406601f, -0.813375f),
+ new Vector3(-0.018073f, -0.197559f, -0.980124f),
+ new Vector3(0.3211f, -0.501477f, -0.80337799f),
+ new Vector3(0.79949099f, 0.069615997f, -0.59662998f),
+ new Vector3(0.390971f, 0.77130598f, -0.50222403f),
+ new Vector3(0.080782004f, 0.61448997f, -0.784778f),
+ new Vector3(-0.73275f, 0.41143101f, -0.54203498f),
+ new Vector3(-0.73525399f, 0.0091019999f, -0.67773098f),
+ new Vector3(-0.80249399f, -0.39490801f, -0.44727099f),
+ new Vector3(-0.13413f, -0.58915502f, -0.79680902f),
+ new Vector3(0.71955299f, -0.37622699f, -0.58369303f),
+ new Vector3(0.96687502f, 0.173593f, -0.187132f),
+ new Vector3(0.760831f, 0.51910597f, -0.38944301f),
+ new Vector3(-0.114642f, 0.87551898f, -0.46938601f),
+ new Vector3(-0.53236699f, 0.76885903f, -0.354177f),
+ new Vector3(-0.96226698f, 0.024977f, -0.27095801f),
+ new Vector3(-0.46738699f, -0.721986f, -0.51018202f),
+ new Vector3(0.058449998f, -0.85235399f, -0.51968902f),
+ new Vector3(0.49823299f, -0.74374002f, -0.44566301f),
+ new Vector3(0.93915099f, -0.27024499f, -0.212044f),
+ new Vector3(0.58393198f, 0.80944198f, -0.061857f),
+ new Vector3(0.183797f, 0.97322798f, -0.138007f),
+ new Vector3(-0.88435501f, 0.45221901f, -0.115822f),
+ new Vector3(-0.943178f, -0.33206701f, 0.012138f),
+ new Vector3(-0.69844002f, -0.70656699f, -0.113772f),
+ new Vector3(-0.228411f, -0.95470601f, -0.190694f),
+ new Vector3(0.73156399f, -0.675861f, -0.089588001f),
+ new Vector3(0.96925098f, 0.046804f, 0.24158201f),
+ new Vector3(0.85564703f, 0.50347698f, 0.119916f),
+ new Vector3(-0.25115299f, 0.96794701f, -0.000080999998f),
+ new Vector3(-0.64779502f, 0.75674897f, 0.087711997f),
+ new Vector3(-0.96916401f, 0.14519399f, 0.1991f),
+ new Vector3(-0.41479301f, -0.88896698f, 0.194126f),
+ new Vector3(0.25077501f, -0.961178f, -0.115109f),
+ new Vector3(0.47862899f, -0.84259301f, 0.246883f),
+ new Vector3(0.89004397f, -0.39614201f, 0.225595f),
+ new Vector3(0.52405101f, 0.76235998f, 0.37970701f),
+ new Vector3(0.11962f, 0.94548202f, 0.30291f),
+ new Vector3(-0.76085001f, 0.49007499f, 0.42536199f),
+ new Vector3(-0.86978501f, -0.20215f, 0.450122f),
+ new Vector3(-0.70946699f, -0.60242403f, 0.36570701f),
+ new Vector3(0.019308999f, -0.95887101f, 0.28318599f),
+ new Vector3(0.626113f, -0.564677f, 0.53770101f),
+ new Vector3(0.769943f, -0.126663f, 0.62541503f),
+ new Vector3(0.76419097f, 0.35070199f, 0.54131401f),
+ new Vector3(-0.001878f, 0.74136698f, 0.67109799f),
+ new Vector3(-0.37088001f, 0.81836802f, 0.43900099f),
+ new Vector3(-0.71390897f, 0.12865201f, 0.68831801f),
+ new Vector3(-0.295165f, -0.73866397f, 0.60601401f),
+ new Vector3(0.186195f, -0.73836899f, 0.648184f),
+ new Vector3(0.387523f, -0.35878301f, 0.84917599f),
+ new Vector3(0.481022f, 0.124846f, 0.86777401f),
+ new Vector3(0.391808f, 0.54505599f, 0.741216f),
+ new Vector3(-0.0035359999f, 0.36559799f, 0.93076599f),
+ new Vector3(-0.42049801f, 0.484961f, 0.76680797f),
+ new Vector3(-0.35490301f, 0.019470001f, 0.93470001f),
+ new Vector3(-0.54783702f, -0.35920799f, 0.75554299f),
+ new Vector3(-0.106662f, -0.445115f, 0.88909799f),
+ new Vector3(0.086796001f, -0.059307002f, 0.99445897f),
+ };
+
+ public static readonly Vector3[] Normals4 = new Vector3[] {
+ new Vector3(0.52657801f, -0.35962099f, -0.77031702f),
+ new Vector3(0.150482f, 0.43598399f, 0.88728398f),
+ new Vector3(0.414195f, 0.73825502f, -0.53237402f),
+ new Vector3(0.075152002f, 0.91624898f, -0.393498f),
+ new Vector3(-0.316149f, 0.93073601f, -0.18379299f),
+ new Vector3(-0.77381903f, 0.62333399f, -0.11251f),
+ new Vector3(-0.90084201f, 0.42853701f, -0.069568001f),
+ new Vector3(-0.99894202f, -0.010971f, 0.044665001f),
+ new Vector3(-0.979761f, -0.15767001f, -0.123324f),
+ new Vector3(-0.91127402f, -0.362371f, -0.19562f),
+ new Vector3(-0.62406898f, -0.72094101f, -0.301301f),
+ new Vector3(-0.310173f, -0.80934501f, -0.498752f),
+ new Vector3(0.146613f, -0.81581903f, -0.55941403f),
+ new Vector3(-0.71651602f, -0.69435602f, -0.066887997f),
+ new Vector3(0.50397199f, -0.114202f, -0.85613698f),
+ new Vector3(0.45549101f, 0.87262702f, -0.176211f),
+ new Vector3(-0.00501f, -0.114373f, -0.99342501f),
+ new Vector3(-0.104675f, -0.327701f, -0.93896502f),
+ new Vector3(0.56041199f, 0.75258899f, -0.34575599f),
+ new Vector3(-0.060575999f, 0.82162797f, -0.566796f),
+ new Vector3(-0.30234101f, 0.79700702f, -0.522847f),
+ new Vector3(-0.671543f, 0.67074001f, -0.314863f),
+ new Vector3(-0.77840102f, -0.12835699f, 0.61450499f),
+ new Vector3(-0.92404997f, 0.278382f, -0.261985f),
+ new Vector3(-0.69977301f, -0.55049098f, -0.45527801f),
+ new Vector3(-0.56824797f, -0.51718903f, -0.64000797f),
+ new Vector3(0.054097999f, -0.93286401f, -0.356143f),
+ new Vector3(0.75838202f, 0.57289302f, -0.31088799f),
+ new Vector3(0.0036200001f, 0.30502599f, -0.95233703f),
+ new Vector3(-0.060849998f, -0.98688602f, -0.14951099f),
+ new Vector3(0.63523f, 0.045478001f, -0.77098298f),
+ new Vector3(0.52170497f, 0.241309f, -0.81828701f),
+ new Vector3(0.26940399f, 0.63542497f, -0.72364098f),
+ new Vector3(0.045676f, 0.67275399f, -0.738455f),
+ new Vector3(-0.180511f, 0.67465699f, -0.71571898f),
+ new Vector3(-0.397131f, 0.63664001f, -0.66104198f),
+ new Vector3(-0.55200398f, 0.47251499f, -0.687038f),
+ new Vector3(-0.77217001f, 0.08309f, -0.62996f),
+ new Vector3(-0.669819f, -0.119533f, -0.73284f),
+ new Vector3(-0.54045498f, -0.31844401f, -0.77878201f),
+ new Vector3(-0.38613501f, -0.522789f, -0.75999397f),
+ new Vector3(-0.261466f, -0.68856698f, -0.676395f),
+ new Vector3(-0.019412f, -0.69610298f, -0.71767998f),
+ new Vector3(0.30356899f, -0.48184401f, -0.82199299f),
+ new Vector3(0.68193901f, -0.19512901f, -0.70490003f),
+ new Vector3(-0.24488901f, -0.116562f, -0.96251899f),
+ new Vector3(0.80075902f, -0.022979001f, -0.59854603f),
+ new Vector3(-0.37027499f, 0.095583998f, -0.92399102f),
+ new Vector3(-0.33067101f, -0.32657799f, -0.88543999f),
+ new Vector3(-0.16322f, -0.52757901f, -0.83367902f),
+ new Vector3(0.12639f, -0.313146f, -0.941257f),
+ new Vector3(0.34954801f, -0.27222601f, -0.89649802f),
+ new Vector3(0.23991799f, -0.085825004f, -0.96699202f),
+ new Vector3(0.390845f, 0.081537001f, -0.91683799f),
+ new Vector3(0.25526699f, 0.26869699f, -0.92878503f),
+ new Vector3(0.146245f, 0.48043799f, -0.86474901f),
+ new Vector3(-0.32601601f, 0.47845599f, -0.81534898f),
+ new Vector3(-0.46968201f, -0.112519f, -0.87563598f),
+ new Vector3(0.81844002f, -0.25852001f, -0.51315099f),
+ new Vector3(-0.474318f, 0.292238f, -0.83043301f),
+ new Vector3(0.778943f, 0.39584199f, -0.48637101f),
+ new Vector3(0.62409401f, 0.39377299f, -0.67487001f),
+ new Vector3(0.74088597f, 0.203834f, -0.63995302f),
+ new Vector3(0.48021701f, 0.565768f, -0.67029703f),
+ new Vector3(0.38093001f, 0.42453501f, -0.82137799f),
+ new Vector3(-0.093422003f, 0.50112402f, -0.86031801f),
+ new Vector3(-0.236485f, 0.29619801f, -0.92538702f),
+ new Vector3(-0.131531f, 0.093959004f, -0.98684901f),
+ new Vector3(-0.82356203f, 0.29577699f, -0.48400599f),
+ new Vector3(0.61106598f, -0.624304f, -0.486664f),
+ new Vector3(0.069495998f, -0.52033001f, -0.85113299f),
+ new Vector3(0.226522f, -0.66487902f, -0.711775f),
+ new Vector3(0.47130799f, -0.56890398f, -0.67395699f),
+ new Vector3(0.38842499f, -0.74262398f, -0.54556f),
+ new Vector3(0.78367501f, -0.48072901f, -0.39338499f),
+ new Vector3(0.962394f, 0.135676f, -0.235349f),
+ new Vector3(0.876607f, 0.172034f, -0.449406f),
+ new Vector3(0.63340503f, 0.58979303f, -0.50094098f),
+ new Vector3(0.182276f, 0.80065799f, -0.57072097f),
+ new Vector3(0.177003f, 0.76413399f, 0.62029701f),
+ new Vector3(-0.544016f, 0.675515f, -0.49772099f),
+ new Vector3(-0.67929697f, 0.28646699f, -0.67564201f),
+ new Vector3(-0.59039098f, 0.091369003f, -0.801929f),
+ new Vector3(-0.82436001f, -0.13312399f, -0.55018902f),
+ new Vector3(-0.71579403f, -0.33454201f, -0.61296099f),
+ new Vector3(0.17428599f, -0.89248401f, 0.416049f),
+ new Vector3(-0.082528003f, -0.83712298f, -0.54075301f),
+ new Vector3(0.28333101f, -0.88087398f, -0.37918901f),
+ new Vector3(0.675134f, -0.42662701f, -0.60181701f),
+ new Vector3(0.84372002f, -0.512335f, -0.160156f),
+ new Vector3(0.97730398f, -0.098555997f, -0.18752f),
+ new Vector3(0.846295f, 0.522672f, -0.102947f),
+ new Vector3(0.67714101f, 0.72132498f, -0.145501f),
+ new Vector3(0.32096499f, 0.87089199f, -0.37219399f),
+ new Vector3(-0.178978f, 0.911533f, -0.37023601f),
+ new Vector3(-0.44716901f, 0.82670099f, -0.341474f),
+ new Vector3(-0.70320302f, 0.496328f, -0.50908101f),
+ new Vector3(-0.97718102f, 0.063562997f, -0.202674f),
+ new Vector3(-0.87817001f, -0.412938f, 0.241455f),
+ new Vector3(-0.83583099f, -0.35855001f, -0.415728f),
+ new Vector3(-0.499174f, -0.69343299f, -0.51959199f),
+ new Vector3(-0.188789f, -0.92375302f, -0.33322501f),
+ new Vector3(0.19225401f, -0.96936101f, -0.152896f),
+ new Vector3(0.51594001f, -0.783907f, -0.34539199f),
+ new Vector3(0.90592498f, -0.30095199f, -0.29787099f),
+ new Vector3(0.99111199f, -0.127746f, 0.037106998f),
+ new Vector3(0.99513501f, 0.098424003f, -0.0043830001f),
+ new Vector3(0.76012301f, 0.64627701f, 0.067367002f),
+ new Vector3(0.205221f, 0.95958f, -0.192591f),
+ new Vector3(-0.042750001f, 0.97951299f, -0.19679099f),
+ new Vector3(-0.43801701f, 0.89892697f, 0.0084920004f),
+ new Vector3(-0.82199401f, 0.48078501f, -0.30523899f),
+ new Vector3(-0.89991701f, 0.081710003f, -0.42833701f),
+ new Vector3(-0.92661202f, -0.144618f, -0.347096f),
+ new Vector3(-0.79365999f, -0.55779201f, -0.24283899f),
+ new Vector3(-0.43134999f, -0.84777898f, -0.30855799f),
+ new Vector3(-0.0054919999f, -0.96499997f, 0.26219299f),
+ new Vector3(0.58790499f, -0.80402601f, -0.088940002f),
+ new Vector3(0.69949299f, -0.66768599f, -0.254765f),
+ new Vector3(0.88930303f, 0.359795f, -0.282291f),
+ new Vector3(0.780972f, 0.197037f, 0.59267199f),
+ new Vector3(0.52012098f, 0.50669599f, 0.68755698f),
+ new Vector3(0.40389499f, 0.69396102f, 0.59605998f),
+ new Vector3(-0.154983f, 0.89923602f, 0.40909001f),
+ new Vector3(-0.65733802f, 0.53716803f, 0.528543f),
+ new Vector3(-0.74619502f, 0.33409101f, 0.575827f),
+ new Vector3(-0.62495202f, -0.049144f, 0.77911502f),
+ new Vector3(0.31814101f, -0.254715f, 0.913185f),
+ new Vector3(-0.555897f, 0.405294f, 0.725752f),
+ new Vector3(-0.79443401f, 0.099405997f, 0.59916002f),
+ new Vector3(-0.64036101f, -0.68946302f, 0.33849499f),
+ new Vector3(-0.12671299f, -0.73409498f, 0.66711998f),
+ new Vector3(0.105457f, -0.78081697f, 0.61579502f),
+ new Vector3(0.40799299f, -0.48091599f, 0.77605498f),
+ new Vector3(0.69513601f, -0.54512f, 0.468647f),
+ new Vector3(0.97319102f, -0.0064889998f, 0.229908f),
+ new Vector3(0.94689399f, 0.317509f, -0.050799001f),
+ new Vector3(0.56358302f, 0.82561201f, 0.027183f),
+ new Vector3(0.325773f, 0.94542301f, 0.0069490001f),
+ new Vector3(-0.171821f, 0.98509699f, -0.0078149997f),
+ new Vector3(-0.67044097f, 0.73993897f, 0.054768998f),
+ new Vector3(-0.822981f, 0.55496198f, 0.121322f),
+ new Vector3(-0.96619302f, 0.117857f, 0.229307f),
+ new Vector3(-0.95376903f, -0.29470399f, 0.058945f),
+ new Vector3(-0.86438698f, -0.50272799f, -0.010015f),
+ new Vector3(-0.53060901f, -0.84200603f, -0.097365998f),
+ new Vector3(-0.162618f, -0.98407501f, 0.071772002f),
+ new Vector3(0.081446998f, -0.99601102f, 0.036439002f),
+ new Vector3(0.74598402f, -0.66596299f, 0.00076199998f),
+ new Vector3(0.94205701f, -0.32926899f, -0.064106002f),
+ new Vector3(0.93970197f, -0.28108999f, 0.194803f),
+ new Vector3(0.77121401f, 0.55067003f, 0.319363f),
+ new Vector3(0.641348f, 0.73069f, 0.23402099f),
+ new Vector3(0.080682002f, 0.99669099f, 0.0098789996f),
+ new Vector3(-0.046725001f, 0.97664303f, 0.20972501f),
+ new Vector3(-0.53107601f, 0.82100099f, 0.209562f),
+ new Vector3(-0.69581503f, 0.65599f, 0.29243499f),
+ new Vector3(-0.97612202f, 0.216709f, -0.014913f),
+ new Vector3(-0.96166098f, -0.14412899f, 0.23331399f),
+ new Vector3(-0.772084f, -0.61364698f, 0.165299f),
+ new Vector3(-0.44960001f, -0.83605999f, 0.314426f),
+ new Vector3(-0.39269999f, -0.91461599f, 0.096247002f),
+ new Vector3(0.390589f, -0.91947001f, 0.044890001f),
+ new Vector3(0.58252901f, -0.79919797f, 0.148127f),
+ new Vector3(0.866431f, -0.48981199f, 0.096864f),
+ new Vector3(0.90458697f, 0.111498f, 0.41145f),
+ new Vector3(0.95353699f, 0.23232999f, 0.191806f),
+ new Vector3(0.497311f, 0.77080297f, 0.398177f),
+ new Vector3(0.194066f, 0.95631999f, 0.218611f),
+ new Vector3(0.422876f, 0.882276f, 0.206797f),
+ new Vector3(-0.373797f, 0.84956598f, 0.37217399f),
+ new Vector3(-0.53449702f, 0.71402299f, 0.4522f),
+ new Vector3(-0.881827f, 0.23716f, 0.40759799f),
+ new Vector3(-0.904948f, -0.014069f, 0.42528901f),
+ new Vector3(-0.751827f, -0.51281703f, 0.41445801f),
+ new Vector3(-0.50101501f, -0.69791698f, 0.51175803f),
+ new Vector3(-0.23519f, -0.92592299f, 0.295555f),
+ new Vector3(0.228983f, -0.95393997f, 0.193819f),
+ new Vector3(0.734025f, -0.63489801f, 0.241062f),
+ new Vector3(0.91375297f, -0.063253f, -0.40131599f),
+ new Vector3(0.90573502f, -0.161487f, 0.391875f),
+ new Vector3(0.85892999f, 0.342446f, 0.38074899f),
+ new Vector3(0.62448603f, 0.60758102f, 0.49077699f),
+ new Vector3(0.28926399f, 0.85747898f, 0.42550799f),
+ new Vector3(0.069968f, 0.90216899f, 0.42567101f),
+ new Vector3(-0.28617999f, 0.94069999f, 0.182165f),
+ new Vector3(-0.57401299f, 0.80511898f, -0.14930899f),
+ new Vector3(0.111258f, 0.099717997f, -0.98877603f),
+ new Vector3(-0.30539301f, -0.94422799f, -0.12316f),
+ new Vector3(-0.60116601f, -0.78957599f, 0.123163f),
+ new Vector3(-0.290645f, -0.81213999f, 0.50591898f),
+ new Vector3(-0.064920001f, -0.87716299f, 0.47578499f),
+ new Vector3(0.408301f, -0.862216f, 0.29978901f),
+ new Vector3(0.56609702f, -0.72556603f, 0.39126399f),
+ new Vector3(0.83936399f, -0.427387f, 0.33586901f),
+ new Vector3(0.81889999f, -0.041305002f, 0.57244802f),
+ new Vector3(0.71978402f, 0.41499701f, 0.55649698f),
+ new Vector3(0.88174403f, 0.45027f, 0.140659f),
+ new Vector3(0.40182301f, -0.89822f, -0.17815199f),
+ new Vector3(-0.054019999f, 0.79134399f, 0.60898f),
+ new Vector3(-0.29377401f, 0.76399398f, 0.57446498f),
+ new Vector3(-0.450798f, 0.61034697f, 0.65135098f),
+ new Vector3(-0.63822103f, 0.186694f, 0.74687302f),
+ new Vector3(-0.87287003f, -0.25712699f, 0.41470799f),
+ new Vector3(-0.58725703f, -0.52170998f, 0.618828f),
+ new Vector3(-0.35365799f, -0.64197397f, 0.680291f),
+ new Vector3(0.041648999f, -0.61127299f, 0.79032302f),
+ new Vector3(0.348342f, -0.77918297f, 0.52108699f),
+ new Vector3(0.499167f, -0.62244099f, 0.602826f),
+ new Vector3(0.79001898f, -0.30383101f, 0.53250003f),
+ new Vector3(0.66011798f, 0.060733002f, 0.74870199f),
+ new Vector3(0.60492098f, 0.29416099f, 0.73996001f),
+ new Vector3(0.38569701f, 0.37934601f, 0.84103203f),
+ new Vector3(0.239693f, 0.207876f, 0.94833201f),
+ new Vector3(0.012623f, 0.25853199f, 0.96591997f),
+ new Vector3(-0.100557f, 0.457147f, 0.88368797f),
+ new Vector3(0.046967f, 0.62858802f, 0.77631903f),
+ new Vector3(-0.43039101f, -0.44540501f, 0.785097f),
+ new Vector3(-0.43429101f, -0.196228f, 0.87913901f),
+ new Vector3(-0.25663701f, -0.336867f, 0.90590203f),
+ new Vector3(-0.131372f, -0.15891001f, 0.97851402f),
+ new Vector3(0.102379f, -0.208767f, 0.972592f),
+ new Vector3(0.195687f, -0.450129f, 0.87125802f),
+ new Vector3(0.62731898f, -0.42314801f, 0.65377098f),
+ new Vector3(0.68743902f, -0.171583f, 0.70568198f),
+ new Vector3(0.27592f, -0.021255f, 0.96094602f),
+ new Vector3(0.45936701f, 0.15746599f, 0.87417799f),
+ new Vector3(0.285395f, 0.583184f, 0.76055598f),
+ new Vector3(-0.81217402f, 0.46030301f, 0.35846099f),
+ new Vector3(-0.189068f, 0.64122301f, 0.743698f),
+ new Vector3(-0.338875f, 0.47648001f, 0.811252f),
+ new Vector3(-0.92099398f, 0.347186f, 0.176727f),
+ new Vector3(0.040638998f, 0.024465f, 0.99887401f),
+ new Vector3(-0.73913199f, -0.35374701f, 0.57318997f),
+ new Vector3(-0.60351199f, -0.28661501f, 0.74405998f),
+ new Vector3(-0.188676f, -0.547059f, 0.81555402f),
+ new Vector3(-0.026045f, -0.39782f, 0.91709399f),
+ new Vector3(0.26789701f, -0.649041f, 0.71202302f),
+ new Vector3(0.518246f, -0.28489101f, 0.80638599f),
+ new Vector3(0.493451f, -0.066532999f, 0.86722499f),
+ new Vector3(-0.328188f, 0.140251f, 0.93414301f),
+ new Vector3(0.328188f, 0.140251f, 0.93414301f),
+ new Vector3(-0.328188f, 0.140251f, 0.93414301f),
+ new Vector3(-0.328188f, 0.140251f, 0.93414301f),
+ new Vector3(-0.328188f, 0.140251f, 0.93414301f),
+ };
+
+ #endregion
+
+}
\ No newline at end of file
diff --git a/src/MapEditorLibrary/Configuration/BrushSize.cs b/src/MapEditorLibrary/Configuration/BrushSize.cs
new file mode 100644
index 000000000..a2844f6bd
--- /dev/null
+++ b/src/MapEditorLibrary/Configuration/BrushSize.cs
@@ -0,0 +1,63 @@
+using MapEditorLibrary.GameMath;
+
+namespace MapEditorLibrary.Configuration;
+
+public class BrushSize
+{
+ public BrushSize(int width, int height)
+ {
+ Width = width;
+ Height = height;
+ }
+
+ public int Width { get; }
+ public int Height { get; }
+ public int Max => Math.Max(Width, Height);
+
+ public Point2D CenterWithinBrush(Point2D point) => new Point2D(point.X - (Width / 2), point.Y - (Height / 2));
+
+ public void DoForBrushSize(Action action)
+ {
+ DoForArea(0, 0, Height, Width, action);
+ }
+
+ public bool CheckForAnyCellInBrushArea(Func checker)
+ {
+ return CheckForAnyCellInArea(0, 0, Height, Width, checker);
+ }
+
+ public void DoForBrushSizeAndSurroundings(Action action)
+ {
+ DoForArea(-1, -1, Height + 1, Width + 1, action);
+ }
+
+ private void DoForArea(int initY, int initX, int height, int width, Action action)
+ {
+ for (int y = initY; y < height; y++)
+ {
+ for (int x = initX; x < width; x++)
+ {
+ action(new Point2D(x, y));
+ }
+ }
+ }
+
+ private bool CheckForAnyCellInArea(int initY, int initX, int height, int width, Func checker)
+ {
+ for (int y = initY; y < height; y++)
+ {
+ for (int x = initX; x < width; x++)
+ {
+ if (checker(new Point2D(x, y)))
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public override string ToString()
+ {
+ return Width + "x" + Height;
+ }
+}
diff --git a/src/MapEditorLibrary/Configuration/ObjectTypeCollection.cs b/src/MapEditorLibrary/Configuration/ObjectTypeCollection.cs
new file mode 100644
index 000000000..9dad051e8
--- /dev/null
+++ b/src/MapEditorLibrary/Configuration/ObjectTypeCollection.cs
@@ -0,0 +1,17 @@
+namespace MapEditorLibrary.Configuration;
+
+public abstract class ObjectTypeCollection
+{
+ public string Name { get; set; }
+ public string UIName { get; set; }
+
+ public List AllowedTheaters { get; set; }
+
+ public bool IsValidForTheater(string theaterName)
+ {
+ if (AllowedTheaters == null || AllowedTheaters.Count == 0)
+ return true;
+
+ return AllowedTheaters.Exists(t => t.Equals(theaterName, StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/src/MapEditorLibrary/Configuration/OverlayCollection.cs b/src/MapEditorLibrary/Configuration/OverlayCollection.cs
new file mode 100644
index 000000000..ed3cb27e8
--- /dev/null
+++ b/src/MapEditorLibrary/Configuration/OverlayCollection.cs
@@ -0,0 +1,74 @@
+using MapEditorLibrary.Misc;
+using MapEditorLibrary.Models;
+using Rampastring.Tools;
+
+namespace MapEditorLibrary.Configuration;
+
+///
+/// Combines many overlays into a single entry.
+///
+public class OverlayCollection : ObjectTypeCollection
+{
+ public struct OverlayCollectionEntry
+ {
+ public OverlayType OverlayType;
+ public int Frame;
+
+ public OverlayCollectionEntry(OverlayType overlayType, int frame)
+ {
+ OverlayType = overlayType;
+ Frame = frame;
+ }
+ }
+
+ public OverlayCollectionEntry[] Entries;
+
+ public static OverlayCollection InitFromIniSection(IniSection iniSection, List overlayTypes)
+ {
+ var overlayCollection = new OverlayCollection();
+ overlayCollection.Name = iniSection.GetStringValue("Name", "Unnamed Collection");
+ overlayCollection.UIName = Translate(overlayCollection, overlayCollection.Name, overlayCollection.Name);
+ overlayCollection.AllowedTheaters = iniSection.GetListValue("AllowedTheaters", ',', s => s);
+
+ var entryList = new List();
+
+ int i = 0;
+ while (true)
+ {
+ string value = iniSection.GetStringValue("OverlayType" + i, null);
+ if (string.IsNullOrWhiteSpace(value))
+ break;
+
+ string[] parts = value.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
+ string overlayTypeName;
+ int frame = 0;
+ if (parts.Length == 1)
+ {
+ overlayTypeName = value;
+ }
+ else
+ {
+ overlayTypeName = parts[0];
+ frame = Conversions.IntFromString(parts[1], -1);
+ }
+
+ var overlayType = overlayTypes.Find(o => o.ININame == overlayTypeName);
+ if (overlayType == null)
+ {
+ throw new INIConfigException($"Overlay type \"{overlayTypeName}\" not found while initializing overlay collection \"{overlayCollection.Name}\"!");
+ }
+
+ if (frame < 0)
+ {
+ throw new INIConfigException($"Frame below zero defined in entry #{i} in overlay collection \"{overlayCollection.Name}\"!");
+ }
+
+ entryList.Add(new OverlayCollectionEntry(overlayType, frame));
+
+ i++;
+ }
+
+ overlayCollection.Entries = entryList.ToArray();
+ return overlayCollection;
+ }
+}
diff --git a/src/MapEditorLibrary/Configuration/SmudgeCollection.cs b/src/MapEditorLibrary/Configuration/SmudgeCollection.cs
new file mode 100644
index 000000000..43253314c
--- /dev/null
+++ b/src/MapEditorLibrary/Configuration/SmudgeCollection.cs
@@ -0,0 +1,54 @@
+using MapEditorLibrary.Misc;
+using MapEditorLibrary.Models;
+using Rampastring.Tools;
+
+namespace MapEditorLibrary.Configuration;
+
+///
+/// Combines many smudges into a single entry.
+///
+public class SmudgeCollection : ObjectTypeCollection
+{
+ public struct SmudgeCollectionEntry
+ {
+ public SmudgeType SmudgeType;
+
+ public SmudgeCollectionEntry(SmudgeType smudgeType)
+ {
+ SmudgeType = smudgeType;
+ }
+ }
+
+ public SmudgeCollectionEntry[] Entries;
+
+ public static SmudgeCollection InitFromIniSection(IniSection iniSection, List smudgeTypes)
+ {
+ var smudgeCollection = new SmudgeCollection();
+ smudgeCollection.Name = iniSection.GetStringValue("Name", "Unnamed Collection");
+ smudgeCollection.UIName = Translate(smudgeCollection, smudgeCollection.Name, smudgeCollection.Name);
+ smudgeCollection.AllowedTheaters = iniSection.GetListValue("AllowedTheaters", ',', s => s);
+
+ var entryList = new List();
+
+ int i = 0;
+ while (true)
+ {
+ string smudgeTypeName = iniSection.GetStringValue("SmudgeType" + i, null);
+ if (string.IsNullOrWhiteSpace(smudgeTypeName))
+ break;
+
+ var smudgeType = smudgeTypes.Find(o => o.ININame == smudgeTypeName);
+ if (smudgeType == null)
+ {
+ throw new INIConfigException($"Smudge type \"{smudgeTypeName}\" not found while initializing smudge collection \"{smudgeCollection.Name}\"!");
+ }
+
+ entryList.Add(new SmudgeCollectionEntry(smudgeType));
+
+ i++;
+ }
+
+ smudgeCollection.Entries = entryList.ToArray();
+ return smudgeCollection;
+ }
+}
diff --git a/src/MapEditorLibrary/Configuration/TerrainGeneratorUserPresets.cs b/src/MapEditorLibrary/Configuration/TerrainGeneratorUserPresets.cs
new file mode 100644
index 000000000..11115befb
--- /dev/null
+++ b/src/MapEditorLibrary/Configuration/TerrainGeneratorUserPresets.cs
@@ -0,0 +1,117 @@
+using MapEditorLibrary.Models;
+using MapEditorLibrary.Mutations.Classes;
+using Rampastring.Tools;
+using System.Globalization;
+
+namespace MapEditorLibrary.Configuration;
+
+public class TerrainGeneratorUserPresets
+{
+ public TerrainGeneratorUserPresets(Map map)
+ {
+ this.map = map;
+ }
+
+ private readonly Map map;
+
+ private const string ConfigFileName = "TerrainGeneratorUserPresets.ini";
+
+ private List configurations = new List();
+
+ public List GetConfigurationsForCurrentTheater()
+ => configurations.FindAll(c => c.Theater.Equals(map.LoadedTheaterName, StringComparison.OrdinalIgnoreCase));
+
+ private bool isDirty;
+
+ public void Load()
+ {
+ string path = GetConfigFilePath();
+ if (!File.Exists(path))
+ return;
+
+ var iniFile = new IniFile(path);
+ int i = 0;
+ while (true)
+ {
+ string sectionName = "Preset" + i.ToString(CultureInfo.InvariantCulture);
+ var iniSection = iniFile.GetSection(sectionName);
+ if (iniSection == null)
+ break;
+
+ var config = TerrainGeneratorConfiguration.FromConfigSection(iniSection, map.Rules, map.TheaterInstance.Theater, true);
+
+ if (config != null)
+ configurations.Add(config);
+ else
+ Logger.Log($"Failed to load terrain generator config from user preset #{i}!");
+
+ i++;
+ }
+ }
+
+ public bool SaveIfDirty()
+ {
+ if (!isDirty)
+ return true;
+
+ return ForceSave();
+ }
+
+ public bool ForceSave()
+ {
+ string path = GetConfigFilePath();
+
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path));
+ }
+ catch (IOException ex)
+ {
+ Logger.Log("IOException while trying to create directories for terrain generator user presets file! Returned error: " + ex.Message);
+ return false;
+ }
+
+ IniFile iniFile = new IniFile();
+
+ int i = 0;
+ configurations.ForEach(c =>
+ {
+ iniFile.AddSection(c.GetIniConfigSection("Preset" + i.ToString(CultureInfo.InvariantCulture)));
+ i++;
+ });
+
+ try
+ {
+ iniFile.WriteIniFile(path);
+ }
+ catch (IOException ex)
+ {
+ Logger.Log("IOException while saving terrain generator presets! Returned error: " + ex.Message);
+ return false;
+ }
+
+ isDirty = false;
+ return true;
+ }
+
+ public void DeleteConfig(string name)
+ {
+ int index = configurations.FindIndex(c => c.Name == name && c.Theater.Equals(map.LoadedTheaterName, StringComparison.OrdinalIgnoreCase));
+ if (index > -1)
+ {
+ configurations.RemoveAt(index);
+ isDirty = true;
+ }
+ }
+
+ public void AddConfig(TerrainGeneratorConfiguration configuration)
+ {
+ if (configurations.Exists(c => c.Name == configuration.Name && c.Theater.Equals(map.LoadedTheaterName, StringComparison.OrdinalIgnoreCase)))
+ throw new ArgumentException($"A configuration with the name {configuration.Name} already exists!");
+
+ configurations.Add(configuration);
+ isDirty = true;
+ }
+
+ private string GetConfigFilePath() => Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), Constants.UserDataFolder, ConfigFileName);
+}
diff --git a/src/MapEditorLibrary/Configuration/TerrainObjectCollection.cs b/src/MapEditorLibrary/Configuration/TerrainObjectCollection.cs
new file mode 100644
index 000000000..c9b46d1e5
--- /dev/null
+++ b/src/MapEditorLibrary/Configuration/TerrainObjectCollection.cs
@@ -0,0 +1,54 @@
+using MapEditorLibrary.Misc;
+using MapEditorLibrary.Models;
+using Rampastring.Tools;
+
+namespace MapEditorLibrary.Configuration;
+
+///
+/// Combines many terrain objects into a single entry.
+///
+public class TerrainObjectCollection : ObjectTypeCollection
+{
+ public struct TerrainObjectCollectionEntry
+ {
+ public TerrainType TerrainType;
+
+ public TerrainObjectCollectionEntry(TerrainType terrainType)
+ {
+ TerrainType = terrainType;
+ }
+ }
+
+ public TerrainObjectCollectionEntry[] Entries;
+
+ public static TerrainObjectCollection InitFromIniSection(IniSection iniSection, List terrainTypes)
+ {
+ var terrainObjectCollection = new TerrainObjectCollection();
+ terrainObjectCollection.Name = iniSection.GetStringValue("Name", "Unnamed Collection");
+ terrainObjectCollection.UIName = Translate(terrainObjectCollection, terrainObjectCollection.Name, terrainObjectCollection.Name);
+ terrainObjectCollection.AllowedTheaters = iniSection.GetListValue("AllowedTheaters", ',', s => s);
+
+ var entryList = new List();
+
+ int i = 0;
+ while (true)
+ {
+ string terrainTypeName = iniSection.GetStringValue("TerrainObjectType" + i, null);
+ if (string.IsNullOrWhiteSpace(terrainTypeName))
+ break;
+
+ var terrainType = terrainTypes.Find(o => o.ININame == terrainTypeName);
+ if (terrainType == null)
+ {
+ throw new INIConfigException($"Terrain object type \"{terrainTypeName}\" not found while initializing terrain object collection \"{terrainObjectCollection.Name}\"!");
+ }
+
+ entryList.Add(new TerrainObjectCollectionEntry(terrainType));
+
+ i++;
+ }
+
+ terrainObjectCollection.Entries = entryList.ToArray();
+ return terrainObjectCollection;
+ }
+}
diff --git a/src/MapEditorLibrary/Constants.cs b/src/MapEditorLibrary/Constants.cs
new file mode 100644
index 000000000..cbb29e713
--- /dev/null
+++ b/src/MapEditorLibrary/Constants.cs
@@ -0,0 +1,202 @@
+using Rampastring.Tools;
+using System.Reflection;
+
+namespace MapEditorLibrary;
+
+public static class Constants
+{
+ public static string Version => Assembly.GetExecutingAssembly().GetName().Version.ToString(3);
+
+ public static int CellSizeX = 48;
+ public static int CellSizeY = 24;
+ public const int CellSizeInLeptons = 256;
+ public static int CellHeight => CellSizeY / 2;
+ public static int HighBridgeHeight = 4;
+ public static int TileColorBufferSize = 576;
+
+ public static int RenderPixelPadding = 50;
+
+ public static bool IsFlatWorld = false;
+ public static bool TheaterPaletteForTiberium = false;
+ public static bool TheaterPaletteForVeins = false;
+ public static bool TiberiumAffectedByLighting = false;
+ public static bool TiberiumTreesAffectedByLighting = false;
+ public static bool TerrainPaletteBuildingsAffectedByLighting = false;
+ public static bool VoxelsAffectedByLighting = false;
+ public static bool NewTheaterGenericBuilding = false;
+ public static bool DrawBuildingAnimationShadows = false;
+ public static bool IsRA2YR = false;
+ public static bool WarnOfTooManyTriggerActions = true;
+ public static bool DefaultPreview = false;
+
+ public static string[] ExpectedClientExecutableNames = new string[] { "DTA.exe" };
+ public static string GameRegistryInstallPath = "SOFTWARE\\DawnOfTheTiberiumAge";
+ public static string OpenFileDialogFilter = "TS maps|*.map|All files|*.*";
+
+ public static bool EnableIniInclude = false;
+ public static bool EnableIniInheritance = false;
+
+ public static bool IntegerVariables = false;
+
+ public static string RulesIniPath;
+ public static string FirestormIniPath;
+ public static string ArtIniPath;
+ public static string FirestormArtIniPath;
+ public static string AIIniPath;
+ public static string FirestormAIIniPath;
+ public static string TutorialIniPath;
+ public static string ThemeIniPath;
+ public static string EvaIniPath;
+ public static string SoundIniPath;
+
+ public const int TextureSizeLimit = 16384;
+
+ public static int MaxMapWidth;
+ public static int MaxMapHeight;
+
+ public const byte MaxMapHeightLevel = 12;
+ public static int MapYBaseline => MaxMapHeightLevel * CellHeight;
+
+ public static int MaxWaypoint = 100;
+
+ public const int ObjectHealthMax = 256;
+ public const int FacingMax = 255;
+
+ public const int TurretFrameCount = 32;
+
+ // TODO parse from Rules.ini
+ public const int ConditionYellowHP = 128;
+
+ public const int VeterancyElite = 200;
+ public const int VeterancyVeteran = 100;
+
+ public const int UIEmptySideSpace = 10;
+ public const int UIEmptyTopSpace = 10;
+ public const int UIEmptyBottomSpace = 10;
+
+ public const int UIHorizontalSpacing = 6;
+ public const int UIVerticalSpacing = 6;
+
+ public const int UIDefaultFont = 0;
+ public const int UIBoldFont = 1;
+
+ public const int UITextBoxHeight = 21;
+ public const int UIButtonHeight = 23;
+
+ public const int UITopBarMenuHeight = 23;
+
+ public static int UITreeViewLineHeight = 20;
+
+ public static double UIAccidentalClickPreventionTime = 0.2;
+
+ public static int MapPreviewMaxWidth = 800;
+ public static int MapPreviewMaxHeight = 400;
+
+ public static int MaxHouseTechLevel = 10;
+
+ public const int MAX_MAP_LENGTH_IN_DIMENSION = 512;
+ public const int NO_OVERLAY = -1;
+ public const int OverlayPackFormat = 80;
+
+ public const string NoneValue1 = "";
+ public const string NoneValue2 = "None";
+
+ public const float RemapBrightenFactor = 1.25f;
+
+ // The resolution of depth rendering. In other words, the minimum depth difference that is significant enough to have an impact on rendering order.
+ public const float DepthEpsilon = 1e-5f;
+
+ // Depth is between 0.0 and 1.0. How much of the scale is reserved for depth increasing as we go southwards on the map.
+ public const float DownwardsDepthRenderSpace = 0.90f;
+
+ // How much of the depth scale (0.0 to 1.0) is reserved for depth increasing as we go up the map height levels.
+ // Calculated dynamically.
+ public static float DepthRenderStep = 0;
+
+ public const string ClipboardMapDataFormatValue = "ScenarioEditorCopiedMapData";
+ public const string ClipboardTriggerActionEventFormatValue = "ScenarioEditorCopiedTriggerData";
+ public const string ClipboardTriggerFormatValue = "ScenarioEditorCopiedTrigger";
+ public const string UserDataFolder = "UserData";
+
+ public const char NewTheaterGenericLetter = 'G';
+
+ public const string VeinholeMonsterTypeName = "VEINHOLE";
+ public const string VeinholeDummyTypeName = "VEINHOLEDUMMY";
+
+ public const int MultiplayerMaxPlayers = 8;
+
+ public const int TS_WAYPT_SPECIAL = 100;
+
+ public const string DefaultHouseTypeName = "Neutral";
+
+ public static void Init()
+ {
+ const string ConstantsSectionName = "Constants";
+ const string FilePathsSectionName = "FilePaths";
+
+ IniFile constantsIni = Helpers.ReadConfigINI("Constants.ini");
+
+ CellSizeX = constantsIni.GetIntValue(ConstantsSectionName, nameof(CellSizeX), CellSizeX);
+ MaxMapWidth = TextureSizeLimit / CellSizeX;
+ CellSizeY = constantsIni.GetIntValue(ConstantsSectionName, nameof(CellSizeY), CellSizeY);
+ MaxMapHeight = TextureSizeLimit / CellSizeY;
+
+ TileColorBufferSize = constantsIni.GetIntValue(ConstantsSectionName, nameof(TileColorBufferSize), TileColorBufferSize);
+
+ RenderPixelPadding = constantsIni.GetIntValue(ConstantsSectionName, nameof(RenderPixelPadding), RenderPixelPadding);
+
+ IsFlatWorld = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(IsFlatWorld), IsFlatWorld);
+ TheaterPaletteForTiberium = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(TheaterPaletteForTiberium), TheaterPaletteForTiberium);
+ TheaterPaletteForVeins = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(TheaterPaletteForVeins), TheaterPaletteForVeins);
+ TiberiumAffectedByLighting = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(TiberiumAffectedByLighting), TiberiumAffectedByLighting);
+ TiberiumTreesAffectedByLighting = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(TiberiumTreesAffectedByLighting), TiberiumTreesAffectedByLighting);
+ TerrainPaletteBuildingsAffectedByLighting = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(TerrainPaletteBuildingsAffectedByLighting), TerrainPaletteBuildingsAffectedByLighting);
+ VoxelsAffectedByLighting = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(VoxelsAffectedByLighting), VoxelsAffectedByLighting);
+ NewTheaterGenericBuilding = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(NewTheaterGenericBuilding), NewTheaterGenericBuilding);
+ DrawBuildingAnimationShadows = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(DrawBuildingAnimationShadows), DrawBuildingAnimationShadows);
+ IsRA2YR = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(IsRA2YR), IsRA2YR);
+ WarnOfTooManyTriggerActions = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(WarnOfTooManyTriggerActions), WarnOfTooManyTriggerActions);
+ DefaultPreview = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(DefaultPreview), DefaultPreview);
+
+ // Check two keys for backwards compatibility
+ if (constantsIni.KeyExists(ConstantsSectionName, "ExpectedClientExecutableName"))
+ ExpectedClientExecutableNames = constantsIni.GetSection(ConstantsSectionName).GetListValue("ExpectedClientExecutableName", ',', s => s).ToArray();
+ else
+ ExpectedClientExecutableNames = constantsIni.GetSection(ConstantsSectionName).GetListValue(nameof(ExpectedClientExecutableNames), ',', s => s).ToArray();
+
+ GameRegistryInstallPath = constantsIni.GetStringValue(ConstantsSectionName, nameof(GameRegistryInstallPath), GameRegistryInstallPath);
+ OpenFileDialogFilter = constantsIni.GetStringValue(ConstantsSectionName, nameof(OpenFileDialogFilter), OpenFileDialogFilter);
+
+ EnableIniInclude = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(EnableIniInclude), EnableIniInclude);
+ EnableIniInheritance = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(EnableIniInheritance), EnableIniInheritance);
+
+ IntegerVariables = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(IntegerVariables), IntegerVariables);
+
+ MaxWaypoint = constantsIni.GetIntValue(ConstantsSectionName, nameof(MaxWaypoint), MaxWaypoint);
+
+ MapPreviewMaxWidth = constantsIni.GetIntValue(ConstantsSectionName, nameof(MapPreviewMaxWidth), MapPreviewMaxWidth);
+ MapPreviewMaxHeight = constantsIni.GetIntValue(ConstantsSectionName, nameof(MapPreviewMaxHeight), MapPreviewMaxHeight);
+
+ MaxHouseTechLevel = constantsIni.GetIntValue(ConstantsSectionName, nameof(MaxHouseTechLevel), MaxHouseTechLevel);
+
+ RulesIniPath = constantsIni.GetStringValue(FilePathsSectionName, "Rules", "INI/Rules.ini");
+ FirestormIniPath = constantsIni.GetStringValue(FilePathsSectionName, "Firestorm", "INI/Enhance.ini");
+ ArtIniPath = constantsIni.GetStringValue(FilePathsSectionName, "Art", "INI/Art.ini");
+ FirestormArtIniPath = constantsIni.GetStringValue(FilePathsSectionName, "ArtFS", "INI/ArtE.ini");
+ AIIniPath = constantsIni.GetStringValue(FilePathsSectionName, "AI", "INI/AI.ini");
+ FirestormAIIniPath = constantsIni.GetStringValue(FilePathsSectionName, "AIFS", "INI/AIE.ini");
+ TutorialIniPath = constantsIni.GetStringValue(FilePathsSectionName, "Tutorial", "INI/Tutorial.ini");
+ ThemeIniPath = constantsIni.GetStringValue(FilePathsSectionName, "Theme", "INI/Theme.ini");
+ EvaIniPath = constantsIni.GetStringValue(FilePathsSectionName, "EVA", "INI/Eva.ini");
+ SoundIniPath = constantsIni.GetStringValue(FilePathsSectionName, "Sound", "INI/Sound01.ini");
+
+ InitUIConstants();
+ }
+
+ public static void InitUIConstants()
+ {
+ IniFile uiConstantsIni = Helpers.ReadConfigINI("UI/UIConstants.ini");
+
+ UITreeViewLineHeight = uiConstantsIni.GetIntValue("UI", nameof(UITreeViewLineHeight), UITreeViewLineHeight);
+ }
+}
diff --git a/src/TSMapEditor/Extensions/IniFileEx.cs b/src/MapEditorLibrary/Extensions/IniFileEx.cs
similarity index 97%
rename from src/TSMapEditor/Extensions/IniFileEx.cs
rename to src/MapEditorLibrary/Extensions/IniFileEx.cs
index db579863f..b23542a34 100644
--- a/src/TSMapEditor/Extensions/IniFileEx.cs
+++ b/src/MapEditorLibrary/Extensions/IniFileEx.cs
@@ -1,10 +1,8 @@
-using System.IO;
-using System.Text;
-using System.Collections.Generic;
+using System.Text;
using Rampastring.Tools;
-using TSMapEditor.CCEngine;
+using MapEditorLibrary.CCEngine;
-namespace TSMapEditor.Extensions;
+namespace MapEditorLibrary.Extensions;
///
/// IniFile with support for Ares #include and Phobos $Include and $Inherits.
diff --git a/src/MapEditorLibrary/Extensions/ListExtensions.cs b/src/MapEditorLibrary/Extensions/ListExtensions.cs
new file mode 100644
index 000000000..8b9154a30
--- /dev/null
+++ b/src/MapEditorLibrary/Extensions/ListExtensions.cs
@@ -0,0 +1,161 @@
+using MapEditorLibrary.Models;
+using Rampastring.Tools;
+
+namespace MapEditorLibrary.Extensions;
+
+public static class ListExtensions
+{
+ public static void ReadTaskForces(this List taskForceList, IniFile iniFile, Rules rules, Action errorLogger)
+ {
+ var section = iniFile.GetSection("TaskForces");
+ if (section == null)
+ return;
+
+ foreach (var kvp in section.Keys)
+ {
+ if (string.IsNullOrWhiteSpace(kvp.Value))
+ continue;
+
+ var taskForce = TaskForce.ParseTaskForce(rules, iniFile.GetSection(kvp.Value), errorLogger);
+ if (taskForce == null)
+ {
+ errorLogger(string.Format(Translate("ListExtensions.TaskForceParseError",
+ "Failed to load TaskForce {0}. It might be missing a section or be otherwise invalid."), kvp.Value));
+
+ continue;
+ }
+
+ int existingIndex = taskForceList.FindIndex(tf => tf.ININame == kvp.Value);
+ if (existingIndex > -1)
+ {
+ taskForceList[existingIndex] = taskForce;
+ }
+ else
+ {
+ taskForceList.Add(taskForce);
+ }
+ }
+ }
+
+ public static void ReadScripts(this List