Great — Here is a comprehensive report on designing a .mul
-compatible open format (like .kool
) that supports both isometric and 2D layouts, balances performance and editability, and works with ClassicUO, ServUO, and Godot (including mobile). This also cover integration using both GDScript and C# where feasible.
1. Overview of Ultima Online’s .mul and .idx Files
Ultima Online’s classic client stores game data in numerous binary .mul
files, often paired with .idx
index files. Each .idx
provides lookup offsets and lengths for data entries in the corresponding .mul
. For example, an index entry is 12 bytes: a 32-bit offset (lookup
), 32-bit length, and an extra 32-bit field (often unused or used for specific metadata). To fetch an entry, the client multiplies the resource ID by 12, reads the index record from the .idx
, then seeks to the given offset in the .mul
file and reads the specified length. This scheme is used for art assets, animations, sound, etc., allowing fixed-length index tables with variable-length binary data blobs.
Major UO file types: The game divides content by type across different .mul
files. Important ones include:
-
Map files (
map0.mul
,map1.mul
, etc.): These hold the base terrain height-map (ground tiles) for each world or facet. UO’s main map is a fixed-size grid of 6144×4096 tiles, divided into 768×512 blocks of 8×8 cells. Each cell stores a terrain tile ID and an elevation byte. The map is essentially a giant matrix of tile IDs and altitudes, streamed in block order (e.g. block 0 is the northwest 8×8 area, etc.). There is no index file for maps – the position of a cell can be computed by its coordinates (e.g.blockIndex = (xBlock * 512) + yBlock
). Each 8×8 block is 196 bytes in the old format (including a 4-byte unused header). -
Static object files (
statics.mul
withstaidx.mul
): These store world objects (buildings, trees, items) placed on the map. For each map block,STAIDX
contains a 12-byte index entry pointing to that block’s static objects inSTATICS.mul
. If a block has no statics, the index offset is0xFFFFFFFF
. Each static object record is 7 bytes: a 2-byte object ID, 1-byte X and 1-byte Y offset within the 8×8 block, a 1-byte altitude (height above ground), and a 2-byte hue/unknown field. (In UO, static object IDs are offset by +0x4000 when looking up art or radar color, to distinguish them from terrain tile IDs.) The client draws statics on top of terrain, sorting by altitude and position so that taller objects appear above lower ones. -
Art files (
art.mul
withartidx.mul
): Contains 2D sprite graphics for terrain and objects. Each entry is a bitmapped image (for example, a tree or wall tile). UO uses two types of art encoding: static-sized 44×44 terrain tiles (stored as a 45° rotated diamond of pixels) and variable-sized “run-length” compressed sprites for objects. TheARTIDX.mul
index gives offset and length for each image. The image data includes flags or a header word to distinguish raw vs. compressed format. All artwork is drawn isometrically in the game (45° rotated grid). -
Animation files (
anim.mul
withanim.idx
, plusanimdata.mul
): These hold multi-frame character and creature animations. The index maps animation IDs to frames inanim.mul
. Each animation consists of a palette and a sequence of frames (with per-frame pixel run data). Animations are compressed with run-length encoding optimized for 2D blitting. -
Gump files (
gumpart.mul
withgumpidx.mul
): “Gumps” are UI art elements (GUI textures like the paperdoll, buttons, etc.). Like art, an index references each UI graphic. Gump images are stored in a simple run-length pixel format per row (inefficient but workable). -
Sound files (
sound.mul
withsoundidx.mul
): Contains sound effects (PCM audio data). The index provides offsets for each sound ID. All sounds in UO are 22 kHz 16-bit mono WAV data. The.mul
holds the raw waveform, while.soundidx
entries give offset, length, and an unused field (plus in newer clients an “index” field for grouping). -
Tile data (
tiledata.mul
): This is a structured file containing metadata for each terrain and item tile ID. Each tile entry includes properties like a name and a bitfield of flags that define game mechanics (e.g. whether a tile is a wall, wet (water), impassable, a surface to stand on, a platform (‘bridge’), etc.). For example, flag0x00000040
= Impassable,0x00000080
= Wet (water),0x00000400
= Bridge (platform),0x00002000
= No Shoot (blocks line-of-sight), and many more. Tiledata links the raw art IDs to game logic (like whether you can walk on it or through it). -
Multi files (
multi.mul
withmulti.idx
): “Multis” are predefined object groups like house designs or ships. The index points to a list of components inmulti.mul
. Each multi entry lists multiple statics with relative X,Y offsets and an altitude, comprising a larger structure. For instance, a house multi might list wall and floor object IDs at various offsets. The client uses these to render player-built houses (often adding 0x4000 to the tile IDs to fetch artwork).
Other files include hues (color palettes) for tinting objects, fonts, lighting tables, map radar color tables, skills data, and verdata (patch overrides). In summary, the UO file ecosystem splits the world into multiple data files (terrain, statics, art, animations, sound, etc.), using indices to allow random access. This structure was efficient for 1990s hardware and allows partial updates (patches could replace specific entries via verdata.mul
or “diff” files). However, .mul
files have no explicit self-description or compression for large datasets (maps are uncompressed and fairly large), and they rely on proprietary content. The goal of “.kool” (a hypothetical new format) is to modernize this while staying compatible in spirit.
2. Legal Considerations for Replicating the Format
When reimplementing or replacing Ultima Online’s file format, it is critical to separate the data format from the game’s original content. The binary format and data structures (e.g. “a map is a grid of blocks with tile IDs and altitudes”) are essentially facts or methods of operation that can be freely reimplemented. File formats per se are generally not protected by copyright. Thus, one can design an open format that is structurally compatible with UO’s (for ease of conversion/interoperability) without violating the law.
However, absolutely none of the original UO assets or proprietary code can be reused in the new project. Every art sprite, sound effect, music track, map layout, and even the specific arrangement of tiles in Britannia is copyrighted by Electronic Arts/Broadsword. As an official forum moderator explicitly stated, “Every asset that comes with Ultima Online is copyrighted – the grass tiles, the music, the sound effects, the UI icons, the fonts, the monster and character graphics, everything. The code that binds these together is copyrighted as well.” Using any of those materials without permission is considered infringement (“theft” in their words). Therefore, an open-source project must create entirely original content (new artwork, new maps, new sounds, etc.) to replace the old assets. Projects like Freedoom (which provides free replacement assets for Doom) demonstrate the viability of this approach: Freedoom produces all-new graphics, music, levels under an open license, allowing the Doom engine to be distributed as a complete free game. By analogy, our .kool
format will carry only original, non-infringing data.
That said, one can safely imitate UO’s file structure and even its data organization concepts. Reverse-engineering efforts over the decades have documented UO’s formats (with no legal challenges known, since no actual EA art is being distributed by format documentation). The new format can use similar concepts like tile IDs, chunked maps, index tables, etc., as these are functional elements. We must avoid trademarked names (“Ultima Online” itself is a trademark), so calling the new format “UO-something” is ill-advised. The name “*.kool” is arbitrary and distinct enough.
One area to be cautious is encryption or protocols. UO’s client communication or any encryption in asset files (the modern client’s .uop
format uses some compression and maybe minor obfuscation) should not be directly reused. Our format should be designed from scratch and openly documented, ensuring it doesn’t incorporate any proprietary algorithm that might be patented or secret. If optional encryption is added (discussed later), it must use standard, license-free algorithms.
Finally, running an alternative ecosystem (using ClassicUO or ServUO with new assets) is generally tolerated by the IP owners but not officially authorized. All original UO shards and clients operate in a gray area legally. By using an original open-source client (ClassicUO) and entirely new assets, we minimize legal risk: there is no distribution of EA’s client or art. The open-source license for the new assets and format will make it clear that this is a fan-made, free project with no claim to EA’s IP. In short, we replicate the functionality and interface of the .mul
ecosystem, but none of the copyrighted content. This is analogous to how free shards or mods operate, except we go one step further by replacing all game data with open content (much like how OpenTTD or Freespace Open projects require free asset replacements to be truly standalone). The result will be a legally clean foundation: open-source code + open data format + open assets.
3. Proposed Open Format and Subformats (.kool*
)
To serve as a modern replacement, the new format family (let’s use “.kool” as a placeholder name) will mirror the modular structure of the original, while introducing improvements for performance, editability, and compatibility. We propose splitting the data into several file types (subformats), each handling a specific aspect of the game data, similar to how UO had separate files. This modular approach is both organizational and practical – it allows updating or streaming parts of the content (e.g. map vs. art) independently.
Planned .kool file types:
-
.koolmap
– World Tile Map Data: Contains the terrain grid and static object placements for a game world (analogous tomap0.mul
+statics.mul
). The.koolmap
will encode the landscape in chunks (for streaming) along with metadata per region. It supports both top-down 2D and isometric tile interpretations. This means the data model is a 2D grid with optional height values, which a client can render as a flat overhead map or in an isometric perspective with elevation. By not baking in projection, the format stays flexible – an engine can treat it as a classic 2D tilemap (ignoring altitude or using it for minor variations) or as an isometric world (using altitude for vertical positioning). We intend.koolmap
to encapsulate static objects as well, so the entire world’s layout is in one file (see Section 4). -
.koolart
– Graphics (Tile and Sprite Art): This will package all the tile graphics and sprites (environment tiles, items, characters, GUI elements if needed) in an open format. Rather than a monolithic binary blob,.koolart
could be an archive (e.g. a simple packed directory or even a ZIP under the hood) containing individual images or texture atlases. However, for performance, we might use a binary atlas format: e.g., store all art in one big texture atlas image with an index table mapping tile IDs to (x,y) regions – or use a sequence of images with a directory listing. The key is to ensure it’s easy to edit and replace art (developers can drop in a PNG and update an index), and easy to load in engines like Godot (which can import images directly). We want an open format that’s not encumbered by patented compression; using standard formats (PNG for images or a simple run-length encoding similar to UO’s) is fine..koolart
will handle both land tiles (which may be fixed-size like 64×32 or 128×64 isometric diamonds, depending on chosen resolution) and object sprites (which can be various dimensions). We may separate these into two sections or two files (like.koolart_land
vs.koolart_obj
) if that simplifies client handling (for example, Godot might treat terrain tilesets differently from object sprites). For now, we’ll consider it one file with an internal distinction or ID range. Each graphic entry can have metadata (for example, bounding box, optional transparency index, etc.) or we rely on image alpha for transparency. -
.koolsnd
– Audio (Sound and Music): An open pack of audio assets (sound effects, ambient sounds, possibly music tracks). This could simply be a directory or archive of Ogg or WAV files with known filenames or IDs. We can design it similar to UO (an index pointing into a big wave file), but modern practice would just use standard audio file formats to avoid reinventing the wheel. However, providing an optional index file (to map numeric sound IDs to filenames or byte offsets) could ease compatibility with servers that refer to sounds by ID..koolsnd
might thus include a manifest (JSON or binary index) listing each sound ID and the path/offset of its audio data. For performance and simplicity, not much compression beyond what the audio codec provides is needed (Ogg Vorbis or FLAC compression is fine and widely supported). -
(Optional)
.kooltile
– Tile Definitions: This would be the equivalent oftiledata.mul
– defining properties for each tile or item ID. Since this is relatively small data (a few thousand entries at most, each with flags, names, etc.), it could be a human-readable format (JSON, XML, or a CSV) or a binary blob. To keep it editor-friendly, a text-based format might be nice (e.g., a JSON file where each tile ID has a dictionary of properties:"name": "Iron Sword", "weight": 6, "flags": ["Wearable","Weapon"], ...
). In an open project, having this data easily tweakable is valuable. We could also integrate this into.koolart
as metadata (for example, store tile properties alongside the art asset), but separating logic data from graphics is probably cleaner. A.kooltile
file can be loaded by the server (ServUO or others) to know game mechanics, and by the client if needed for tooltips or effects. -
(Optional)
.koolmulti
– Multi-object Templates: If we want to support UO-style housing or prefabs, we can provide a format akin tomulti.mul
. This would list compositions of tiles (e.g., a house layout made of multiple tile IDs at relative positions). However, this might also be rolled into.koolmap
or just handled on the server side. Since the question doesn’t explicitly ask for multis, we can consider it a future extension or include it in the map as special regions (like “house design” region). -
Future encryption/validation layer: The format family is meant to be open and easily readable (to encourage community contributions and transparency). However, for certain deployments, shard owners might want to protect their custom assets or ensure clients aren’t tampering with files. We can design the format such that encryption or signing can be layered on optionally. For example, a
.kool
file could have an optional header section indicating encryption, after which the bulk of the data is encrypted with a key (the client would need that key). This could be as simple as wrapping the file in an AES encryption layer, or requiring a hash check. Another approach is to allow a separate manifest file with hashes (to validate integrity). These features wouldn’t be used in the default open-source distribution (to keep everything open and modifiable), but the format will be extensible to accommodate them later. We will reserve an identifier or flag in the header for encryption, so that if encryption is off (default), any client can read the data, but if on, the engine knows it must decrypt (likely with a key obtained securely from the server or user). All encryption would use non-proprietary algorithms (e.g., AES, RSA for keys) to avoid legal issues and to allow open implementations.
Compatibility goals: The .kool
suite is designed to be structurally compatible with the UO ecosystem so that existing tools and servers can be adapted with minimal effort. The data can be converted to classic .mul
if needed. For instance, .koolmap
can be converted into map0.mul
+ statics.mul
and their indexes by a tool, allowing ClassicUO or the original client to load the world (with new content). Likewise, a converter could take existing .mul
files and output .kool
files (useful for testing the system with UO’s map if one has legal access, or for players who want to use the new client format with old assets, purely for personal use). We are essentially creating an interchange format that can funnel data into ClassicUO/ServUO or into modern engines like Godot. ClassicUO being open source means we could also modify it to read .kool
files directly at runtime, bypassing conversion. For example, one could write a plugin or extend ClassicUO’s asset loader to accept a .koolmap
file – since the format is public, it’s just coding work. ServUO (an emulator in C#) could likewise be extended to load .kool
assets for server-side functions (like region definitions or tile flags). In the interim, providing conversion utilities (Section 8) will be important to bridge the ecosystems. Compatibility also means using non-infringing IDs and structures: we might use the same numeric ranges for tile IDs (so that ServUO’s existing item scripts referencing, say, item ID 0x0F00 for a chair, can work if we assign our new chair art that ID). We have to be careful: using the exact UO IDs for items could be a double-edged sword legally if those IDs correspond to copyrighted art (because the mapping of ID to art is kind of a fact, but if someone were to use the new client with the old server scripts, the server might expect certain behaviors for those IDs). However, if our goal is to be a drop-in replacement, matching some ID conventions might be beneficial. For now, we can design .kool
to allow flexible ID mapping (e.g., include a mapping table if needed), but assume we’ll define our own tile and item ID namespace for our original content.
In summary, the .kool
format family will be modular, open, and friendly to both streaming performance and editor modification. Next, we’ll delve into the specific design of the core piece, .koolmap
, which is one of the most complex parts (since it combines world terrain, statics, and regions).
4. .koolmap
Format Specification
The .koolmap
file stores an entire game map (terrain and static objects). It is designed with the following in mind: (a) easy streaming of world chunks (so clients can load areas on the fly or only keep nearby regions in memory), (b) easy editing of small parts (so an editor can update a city or add a building without rewriting the whole file), (c) support for both flat 2D and isometric views, (d) future-proofing via versioning and optional compression/encryption.
Let’s break down the binary layout of .koolmap
:
-
File Header: A fixed-size header at the start of the file provides identification and global info. For example, we use a few bytes for an ASCII signature, like
KOOLMAP
(7 bytes) plus a null terminator or version byte, so that tools immediately recognize the format. The header includes a version number (e.g., 16-bit or 32-bit integer) which will be incremented if we change the format in incompatible ways. We also include overall map dimensions (width and height in tiles, or in blocks), and block size (the size of a chunk in tiles, likely 8×8 as in UO for familiarity). For instance, classic UO map0 is 6144×4096 tiles, block size 8, thus 768×512 blocks – those numbers would be in the header. Additionally, the header contains flags or fields for any global settings:- A compression flag or method field (e.g., 0 = no compression, 1 = zlib/DEFLATE, 2 = LZ4, etc.), indicating whether chunk data is compressed and how.
- An encryption flag (if we allow encrypted content, e.g., 0 = none (default), 1 = encrypted with key).
- Possibly a checksum or CRC for the entire file (or for quick validation of file integrity).
- A chunk index offset and size (if the index is not directly after the header).
- Reserved space for future use (we can pad the header to, say, 64 bytes or more, leaving room for additional fields later without breaking version 1).
For example, the header might be defined in C-like pseudocode as:
struct KoolMapHeader { char magic[8]; // "KOOLMAP\0" uint32 version; // e.g., 0x00010000 for version 1.0 uint32 mapWidth; // in tiles uint32 mapHeight; // in tiles uint16 blockWidth; // e.g., 8 uint16 blockHeight; // e.g., 8 (usually same as blockWidth for square blocks) uint32 totalBlocks; // just for convenience (mapWidth/blockWidth * mapHeight/blockHeight) uint8 compression; // 0 = none, 1 = zlib, 2 = LZ4, etc. uint8 encryption; // 0 = none, 1 = AES-128-CTR, etc. (if implemented) uint16 reserved_flags; uint32 indexOffset; // offset in file where chunk index starts (if not immediately after header) uint32 indexCount; // number of index entries (should equal totalBlocks) uint32 indexEntrySize; // size of one index entry in bytes (for flexibility, likely 8 or 12 bytes) // ... possibly more reserved fields or a 16-byte hash for file? };
This header structure (exact fields can vary) ensures any program can sanity-check the file (correct magic, supported version) before proceeding.
-
Chunk Index Table: Following the header, we include an index of chunks (unless we choose to place it elsewhere, but typically it’s convenient right after the header). This index serves a similar purpose to UO’s
.idx
files but is embedded in the.koolmap
for self-containment. Each index entry corresponds to a map block (which is a fixed region of the world, e.g., an 8×8 tile area). The index entry likely contains:- The file offset where that chunk’s data is stored (relative to the start of the file or maybe after the index section).
- The compressed size of the chunk data (and possibly the uncompressed size, if needed for allocation or to skip without decompressing).
- Potentially a short checksum for that chunk or a timestamp for last modification (the latter can be useful for editors to track changes, similar to how Minecraft’s region format stores a timestamp for each chunk).
We expect each index entry to be 8 or 12 bytes. For example, 8 bytes could be enough: 3 bytes for offset (if using 4096-byte sectors like Minecraft did, but we may not do sector alignment), 3 bytes for length, plus 2 bytes for a checksum or reserved. However, to avoid arbitrary limits, we might use 64-bit offsets and 32-bit lengths in a 12-byte entry. Given maps can be quite large, 32-bit offset (4 GB limit) might be okay if each map file <4GB, but using 64-bit is future-proof.
The chunk index will have one entry per block in a predictable ordering (e.g., block [0] = top-left 8×8 area of map, then row-major order). The number of entries equals
totalBlocks
(which is width_in_blocks * height_in_blocks, given in header). The client can locate a block’s entry by its block ID. If we maintain the same ordering as UO (blocks counted row by row), conversion and compatibility calculations are easier (we can use UO’s formula:blockID = (xBlock * blocks_y) + yBlock
).Example: If block 0 is at offset 256 bytes in the file and has length 100 bytes compressed, index[0] would store 256 and 100. If a block has no data (e.g., entirely empty static-wise), we might mark its offset as 0 and length 0, or have a flag for empty (like UO used 0xFFFFFFFF for no statics). Since every block at least has terrain data, we won’t have truly “empty” blocks; but we might compress a completely flat empty block to a very small size.
The index allows quick seeking: if a player moves to a new region, the client can jump to the needed block data without reading intervening blocks. It also allows the file to be edited in-place: if one chunk’s size changes (e.g., an edit adds many static objects making it bigger), we can append the new chunk data at file end and update that block’s index entry to point to the new location, leaving the old data as garbage or to be reclaimed on a re-pack. This avoids rewriting the whole file for small edits – an editor can work chunk by chunk. (This is similar in spirit to Minecraft’s region files, which use a fixed index at the top and can move chunks around, though they align chunks to sectors to ease expansion. We might not fix sector size, but we could for simplicity or even adopt 4KB sector alignment to reduce fragmentation).
-
Chunk Data (Terrain & Statics Blocks): After the index (or possibly interleaved with it if we chose not to centralize the index), the bulk of the file is the actual chunk data for each block. Each chunk contains:
-
A small chunk header or identifier (optional). Could include the block ID or coordinates for redundancy (so if you jump to a chunk, you can verify it’s the correct one), and the uncompressed size (if compression is used). In a simple design, we might omit an internal header and rely on the index for all needed info. Alternatively, a 1-byte or 2-byte chunk type identifier (for future extensibility, e.g., different chunk format versions) could be included.
-
Terrain cell data: The ground tiles for that 8×8 (or configurable size) block. We will likely store this as an array of 64 entries (row-major order within the block). Each entry can be:
- 2 bytes for the tile graphic ID (allowing up to 65535 different terrain tile types, which is plenty; UO used 16-bit for tile IDs).
- 1 byte for altitude (heights from -128 to +127, exactly as UO did). We could consider 2 bytes for altitude if we wanted higher vertical range (e.g., in a fully 3D world you might exceed 127 high), but since this is for a 2D/iso hybrid, 1 byte (±128) is probably sufficient and matches many game constraints. It can always represent heights in half-meter or meter units as needed.
- We could include an optional byte of terrain metadata per cell – for example, a byte that encodes ground texture variation or region index. But that might bloat the array. Instead, region info will be handled separately (see below), and terrain type is already the tile ID.
- So baseline: 3 bytes per cell, identical to UO’s cell format, to facilitate easy conversion and familiarity. 64 cells -> 192 bytes, plus maybe padding to align chunk if needed.
We might also consider compressing these 64 cells with a tiny algorithm (but general file compression will probably shrink them well if there are repeating patterns). Given 192 bytes per block uncompressed, across an entire large map (393,216 blocks for a full UO map), that’s ~72MB just for terrain. With compression, this can drop massively since terrain has large swathes of repeated values (water, plains, etc.). We’ll rely on chunk compression to reduce this.
-
Static objects data: Following the terrain cells of the block, we list all static objects that reside in that block. In UO, statics for each block were stored in a separate file and index, but here we integrate them for cohesion. We can use a similar 7-byte structure for each static object, perhaps extended slightly if needed:
- 2 bytes: Object ID (refers to an entry in
.koolart
for the object’s sprite). - 1 byte: X offset in the block (0 to blockWidth-1).
- 1 byte: Y offset in the block (0 to blockHeight-1).
- 1 byte: Altitude (relative elevation above the terrain, same scale as terrain altitude; can be negative for things below ground or positive for raised items).
- 2 bytes: Flags/Extra – could denote hue (tint) or other properties. In UO this was unused or used for hue in some contexts. We can define bits here for things like: whether this static is animated, or blocking, or a map marker, etc., but a lot of that is defined in tile properties globally. It might also carry a hue/color modifier if we want to support tinted objects (like how UO can dye certain items).
- We might include a quantity or instance ID if needed for certain server-side references, but that’s likely unnecessary at the map file level (it’s more for inventory items).
All static entries for the block are listed sequentially. We may terminate the list with a special marker (like a 0xFFFF object ID as EOF) or, since the index knows the total length of the chunk, we implicitly know how many statics are present by reading until the chunk end.
Importantly, static entries should be sorted by Y then X then altitude for proper drawing order in isometric views (UO sorted them by height when rendering). We could pre-sort them in the file to make the client’s job easier (the client can then just draw in file order after separating by altitude if needed). UO’s static files did not guarantee sorting; the client had to sort each block’s statics by altitude at runtime for drawing. We can improve this by storing them sorted, but it’s a minor optimization. It might be better to just store as entered and let the renderer handle sorting (especially if dynamic objects will also be present).
We will also consider if we need multiple layers of terrain (like multi-level dungeons). UO’s map had a single layer with altitude; multi-level structures (like caves under ground) were handled via statics and altitude.
.koolmap
can similarly represent multi-story structures: the ground tile is base terrain, and upper floors are statics with higher altitude (like a wooden floor at Z=20). This works as long as the client knows how to render or cull by altitude. If an engine needed true separate layers, we could incorporate a layer concept (e.g., chunk divided into layer sections), but likely not necessary for compatibility. - 2 bytes: Object ID (refers to an entry in
-
-
Region and Metadata Chunk (optional): In addition to raw tiles and statics, game worlds often have region definitions (for zones like cities, dungeons, lawless areas, etc.), terrain types (like an ID for “forest” or “swamp” for certain tiles), and other metadata that doesn’t fit in per-tile flags alone. We plan to include a section in
.koolmap
(possibly at the end of the file, or distributed per chunk) to hold such info. There are a few approaches:- Global region table: e.g., at the end of the file we have a list of regions (with their polygonal boundaries or rectangular bounds, names, and attributes like “Guarded=Yes”). This might not be chunked, but rather a whole-map description. Because region shapes can be complex, a simple method is to use a bitmap or tile-tagging: e.g., a 2D array of region IDs aligned with the map. However, that can be large and inefficient if regions are irregular. Alternatively, define each region by a set of rectangles or an explicit list of tiles. For our open format, we could include a region layer: basically an overlay grid of the same dimensions as the map, where each cell has a region index (0 if none). That would be an array of
mapWidth * mapHeight
integers – too large to store explicitly (especially since many cells share region). Instead, it’s more efficient to store region shapes in a vector form (list of boundaries). - Per-chunk metadata: We could also associate region data with chunks: e.g., each chunk could contain a small list like “regionID for this block” if the block is wholly in one region, or a mask of which cells belong to which region if it straddles a boundary. This gets complicated for arbitrary shapes. Perhaps best is to keep region info as a separate structure in the file: a header listing all regions with properties, and then a list of tiles or chunks belonging to each. Since the question specifically mentions “Use region and terrain metadata”, we should accommodate region definitions in the format.
For now, we can outline that
.koolmap
will have a Region Section after all chunks:- First, a table of region definitions: Each region has an ID, a name (string), and flags (like whether PvP is allowed, whether guards spawn, music to play, etc.—this replicates what shard scripts often handle).
- Second, region geometry: could be a list of rectangles or tile ranges for each region, or a compressed mask. Possibly simplest is list of rectangles: e.g., Britain city region: multiple sub-rectangles covering the city area. This is flexible and fairly compact if regions are mostly rectangular. If needed, more complex shapes can be approximated by multiple rects.
- Alternatively, we might rely on server-side region definitions (ServUO defines regions in scripts), and not bake it into the map file at all. But the question suggests including it, likely to assist the Godot integration (maybe for triggering music or effects in the client).
Terrain metadata such as “terrain type” (grass, sand, water) is often derivable from tile IDs and tiledata flags (e.g., any tile with Wet flag is water). We might not need separate encoding for terrain type if our tile IDs inherently indicate that. However, if one wanted, we could assign a biome or terrain code per tile ID or per region of tiles – but that’s out of scope for the core format spec (it can be handled via tile properties or region attributes).
- Global region table: e.g., at the end of the file we have a list of regions (with their polygonal boundaries or rectangular bounds, names, and attributes like “Guarded=Yes”). This might not be chunked, but rather a whole-map description. Because region shapes can be complex, a simple method is to use a bitmap or tile-tagging: e.g., a 2D array of region IDs aligned with the map. However, that can be large and inefficient if regions are irregular. Alternatively, define each region by a set of rectangles or an explicit list of tiles. For our open format, we could include a region layer: basically an overlay grid of the same dimensions as the map, where each cell has a region index (0 if none). That would be an array of
To summarize the .koolmap
binary structure in order:
-
Header (with identification, version, dimensions, flags, index location).
-
Chunk Index Table (array of fixed-size entries: offset & length [ & perhaps checksum ] for each chunk).
-
Chunks: For each block (in some predetermined sequence, likely row-major by block):
- Terrain cell array (blockWidth * blockHeight entries, each 2 bytes ID + 1 byte altitude).
- Static object list (N entries of 7 bytes each, or extended size if we add fields).
- (If compression is enabled, the above data is stored compressed as a whole; if not, it’s raw).
-
Region/Metadata Section (optional, possibly at end of file, or signaled by a special index entry or header field):
- Region definitions (ID, name, flags…).
- Region shapes (tile ranges or lists).
- Possibly other global metadata like default terrain type mapping or map descriptions.
We should illustrate a small example of how a chunk might look in hex or pseudo-binary:
Suppose we have a 4×4 tile mini-map (block size 2, so 2×2 blocks total). The header would say width=4, height=4, blockWidth=2, blockHeight=2, totalBlocks=4. The index would have 4 entries. Let’s say block0 offset=… etc. Each block’s uncompressed data is 223 = 12 bytes for terrain, plus statics. If block0 had one static, that’s 7 bytes, so 19 bytes total uncompressed. Compressed it might be, say, 15 bytes. The index would record that.
The point is, .koolmap
is self-contained and chunk-addressable. This approach balances performance and editability:
- Performance: A client can quickly seek to relevant chunks and load only those. Large maps can be partially loaded. If compression is per-chunk, the client only has to decompress small pieces at a time (which is memory-friendly and CPU-friendly on modern hardware). This is similar to how modern games (and Minecraft) manage world streaming – small compressed chunks on disk for quick I/O. It also means patches can be efficient: if one chunk changes, only that chunk’s data needs to be updated. In fact, UO’s newer format
.uop
(used in later clients) is an archive that compresses data, but it was observed that patching.uop
can be inefficient because compression can make small changes cascade into big binary diffs. ClassicUO actually converts.uop
assets back to.mul
for patching because uncompressed.mul
allowed smaller delta patches. We take a cue from this: by chunking the compression, local changes remain local. A small map edit won’t reflow the entire file. - Editability: Tools can open
.koolmap
and treat it almost like a database of blocks. For instance, an editor could load the index, then seek and decompress only the block the user is currently editing, modify it, and write it back (possibly appending new data if it grows). The index is then updated for that block. This is similar to how a filesystem or region file might work – no need to rewrite the whole file for one change, which is great for large world editing. The presence of clear structuring (header, index, chunks) makes writing an editor straightforward. Contrast with raw.mul
: UO’s map was uncompressed fixed-size blocks (easy to edit in place, but no compression), and statics were a contiguous list per block but required maintaining parallel index – we unify those for simplicity.
Data integrity: Because .koolmap
might be user-edited, including some form of validation is wise. We might store a simple checksum per chunk that the client can verify after loading, or a global hash to verify the entire file (server could send an expected hash to the client to ensure they have the right version). This is in the “optional” category but helps detect corrupted or tampered files. Open source philosophy suggests not forcing checks that impede modding, but giving server admins the option to validate assets.
To conclude the spec: any engine or tool reading .koolmap
will:
- Read and verify the header (check
magic
, version compatibility). - Read the index table (either directly after header or at
indexOffset
). - For each needed block, get its offset/length from the index, seek there, read the chunk data (decompress if needed) into a buffer.
- Parse terrain cells and static objects from that buffer.
- Use tile IDs to look up art for rendering, use altitudes for positioning, etc. (We will detail this in the Godot integration section.)
- Use region metadata as needed (for triggers like music or restrictions when player enters a region).
This covers .koolmap
in detail. The format is binary for compactness, but fully documented so that it remains open. We’ll now discuss how one would implement reading and writing this format in code (to demonstrate practicality).
5. Reading and Writing .koolmap
: Implementation Strategies (Python & C++)
To ensure the format is truly “open source–friendly,” we should provide reference implementations or code snippets. Below we illustrate how to read and write a .koolmap
in both Python (good for quick prototyping or conversion scripts) and C++ (for integration into high-performance engines or tools).
Python Implementation Example
Python, with its struct
module and rich libraries, is well-suited to parse binary files like .koolmap
. Here’s a simplified example of reading a .koolmap
file and iterating through its blocks:
import struct, zlib
# Open the .koolmap file in binary mode
with open("world.koolmap", "rb") as f:
# Read and unpack header (assuming the header structure from section 4)
header_data = f.read(64) # let's assume our header is 64 bytes
magic, version, width, height, bW, bH, total_blocks, comp, enc, res_flags, idx_off, idx_count, idx_entry_size = \
struct.unpack("<8s I I I H H I B B H I I I", header_data)
magic = magic.decode('ascii').rstrip('\0')
if magic != "KOOLMAP":
raise Exception("Not a KOOLMAP file or bad magic!")
print(f"Map size: {width}x{height} tiles, block size: {bW}x{bH}, total blocks: {total_blocks}")
# Read index entries
f.seek(idx_off)
index_entries = []
for i in range(idx_count):
# Assuming index entry = 12 bytes: 8-byte offset, 4-byte length
data = f.read(12)
if len(data) < 12:
raise Exception("Index truncated")
offset, length = struct.unpack("<Q I", data) # little-endian
index_entries.append((offset, length))
# Iterate over blocks (for demonstration, we’ll print info about first few blocks)
for block_id, (offset, length) in enumerate(index_entries[:5]):
if offset == 0xFFFFFFFF or length == 0:
# No data (e.g., a completely empty block) – skip
continue
f.seek(offset)
comp_data = f.read(length)
if comp == 1: # e.g., 1 = zlib compressed
raw_data = zlib.decompress(comp_data)
else:
raw_data = comp_data # no compression
# Now parse the chunk's raw_data
# First part: terrain cells
cell_count = bW * bH # e.g., 64
cells = []
# Each cell: 3 bytes (uint16 id + int8 altitude)
for j in range(cell_count):
id_val, alt_val = struct.unpack_from("<Hb", raw_data, j*3)
cells.append((id_val, alt_val))
# After terrain cells, static entries
statics = []
statics_offset = cell_count * 3
# Read until end of chunk data
k = statics_offset
while k < len(raw_data):
(obj_id,) = struct.unpack_from("<H", raw_data, k)
if obj_id == 0xFFFF: # hypothetical end marker (could also rely on length)
break
x_off, y_off, alt, flags = struct.unpack_from("<BBbH", raw_data, k+2)
statics.append((obj_id, x_off, y_off, alt, flags))
k += 7
print(f"Block {block_id}: first terrain tile ID={cells[0][0]}, altitude={cells[0][1]}, statics={len(statics)} objects.")
In this Python snippet, we:
- Use
struct.unpack
with<
(little-endian) format strings to parse binary according to our known header and index structure. - Handle compression via Python’s
zlib
library if needed (assuming zlib/DEFLATE method). - Parse terrain cells and statics sequentially from the raw chunk data.
- We demonstrate reading only the first 5 blocks for brevity, printing the first cell and number of statics.
Writing a .koolmap
in Python would be the reverse process: assemble the header bytes, then build the index (initially maybe all zeros as placeholders), then append each chunk’s data while recording its offset and length, then go back and write the index. Python can easily handle that with struct.pack
and file writes. One would likely use zlib.compress()
on each block’s data if compression is enabled.
The code above is simplified (it doesn’t handle region metadata, and assumes a fixed index entry size), but it shows that reading the format is straightforward using basic Python. This is important for community adoption: content creators can write Python scripts to generate or tweak maps (e.g., procedural generation or batch modifications) without needing a complex SDK.
C++ Implementation Example
In C++, one might parse .koolmap
using file streams or memory-mapped files for efficiency. We’ll outline a reading process using C++ pseudo-code:
#include <fstream>
#include <cstdint>
#include <vector>
#include <stdexcept>
#include <zlib.h> // if using zlib for decompression
struct KoolMapHeader {
char magic[8];
uint32_t version;
uint32_t width, height;
uint16_t blockWidth, blockHeight;
uint32_t totalBlocks;
uint8_t compression;
uint8_t encryption;
uint16_t reserved;
uint32_t indexOffset;
uint32_t indexCount;
uint32_t indexEntrySize;
// ... (assuming header was padded to fixed size)
};
// Index entry structure for this example
#pragma pack(push,1)
struct ChunkIndexEntry {
uint64_t offset;
uint32_t length;
// (could have uint32_t checksum or timestamp here as well)
};
#pragma pack(pop)
struct TerrainCell { uint16_t id; int8_t altitude; };
struct StaticObj { uint16_t id; uint8_t x; uint8_t y; int8_t z; uint16_t flags; };
int main() {
std::ifstream fin("world.koolmap", std::ios::binary);
if(!fin) throw std::runtime_error("File open error");
KoolMapHeader hdr;
fin.read(reinterpret_cast<char*>(&hdr), sizeof(hdr));
if(strncmp(hdr.magic, "KOOLMAP", 7) != 0) {
throw std::runtime_error("Not a KOOLMAP file");
}
std::vector<ChunkIndexEntry> index(hdr.indexCount);
fin.seekg(hdr.indexOffset);
fin.read(reinterpret_cast<char*>(index.data()), hdr.indexCount * sizeof(ChunkIndexEntry));
// Read a specific block, e.g., block number 100
uint32_t blockId = 100;
if(blockId >= index.size()) return -1;
ChunkIndexEntry entry = index[blockId];
if(entry.offset == UINT64_MAX || entry.length == 0) {
// no data
} else {
fin.seekg(entry.offset);
std::vector<char> compData(entry.length);
fin.read(compData.data(), entry.length);
std::vector<char> rawData;
if(hdr.compression == 1) {
// decompress using zlib
uLongf destLen = hdr.blockWidth * hdr.blockHeight * 3 // terrain
+ /* some max statics size */;
rawData.resize(destLen);
if(uncompress(reinterpret_cast<Bytef*>(rawData.data()), &destLen,
reinterpret_cast<Bytef*>(compData.data()), entry.length) != Z_OK) {
throw std::runtime_error("Decompression failed");
}
rawData.resize(destLen);
} else {
rawData.assign(compData.begin(), compData.end());
}
// Parse terrain cells:
size_t cellCount = hdr.blockWidth * hdr.blockHeight;
std::vector<TerrainCell> cells(cellCount);
for(size_t i=0; i<cellCount; ++i) {
uint16_t id = *reinterpret_cast<uint16_t*>(&rawData[i*3]);
int8_t alt = *reinterpret_cast<int8_t*>(&rawData[i*3 + 2]);
cells[i] = { id, alt };
}
// Parse statics:
std::vector<StaticObj> statics;
size_t pos = cellCount * 3;
while(pos + 7 <= rawData.size()) {
uint16_t objId = *reinterpret_cast<uint16_t*>(&rawData[pos]);
if(objId == 0xFFFF) { // end marker if used
break;
}
uint8_t sx = rawData[pos+2];
uint8_t sy = rawData[pos+3];
int8_t sz = *reinterpret_cast<int8_t*>(&rawData[pos+4]);
uint16_t flags = *reinterpret_cast<uint16_t*>(&rawData[pos+5]);
statics.push_back({ objId, sx, sy, sz, flags });
pos += 7;
}
std::cout << "Block " << blockId << " has " << statics.size() << " static objects." << std::endl;
}
return 0;
}
In this C++ example, we define structs for the header and index for direct reading (using #pragma pack(1)
to avoid padding issues, or we could read fields individually). We then open the file, read the header and check the magic string. We allocate an index array and read all index entries in one go (since they are a fixed-size array). Then we demonstrate reading block 100: seeking to the offset, reading compressed data, decompressing with uncompress()
from zlib (assuming compression method 1 is zlib DEFLATE). We then parse the bytes for terrain and statics similarly to the Python approach.
Writing in C++ would involve similar steps in reverse: computing chunk data for each block, compressing if needed, writing out the header (with a placeholder index offset), writing a blank index table (or remembering positions to fill later), writing each chunk and storing its offset/length, then going back to write the index table. One could also build everything in memory and then output, but for very large maps that might be heavy; better to stream out chunk by chunk.
Memory considerations: Using 64-bit file offsets (as in uint64_t offset
) ensures we can handle very large map files (bigger than 4GB). A full Britannia with compression might be under 1GB, but if uncompressed and extended, who knows – so 64-bit is safe.
Endianness: We’re using little-endian for all multi-byte values, matching Intel/x86 and also the original UO format (which was little-endian). This is pretty standard for binary game formats (and since our target platforms include little-endian PCs and mobile devices, this is fine – if some day a big-endian system needs it, they’d swap bytes, but that’s rare now).
The above code sketches show that neither Python nor C++ has any trouble handling the format. We leveraged zlib in both, since it’s common and free. Also note that both languages would benefit from using a well-known container or library if available (for example, if someone made a library libkool
for reading these files, that would be ideal – we could create one in C++ and Python could use it via bindings, etc., but the raw parsing is straightforward enough as shown).
6. Integration with Godot 4.4.1 (Examples in GDScript and C#)
One of the goals is to use this new format with the Godot Engine (v4.4.1), including on mobile platforms. Godot is a powerful open-source engine which supports both GDScript (its Python-like scripting language) and C# (via Mono). We will discuss how to load and render a .koolmap
file in Godot, apply isometric sorting for elevation, use region metadata in-game, and note mobile considerations.
First, importing or loading .koolmap
in Godot: There are two general approaches – in-engine parsing at runtime or writing a Godot plugin to import it as a resource. We’ll focus on runtime loading here (as if you want to dynamically load the map when the game runs), and in Section 7 we’ll mention an Editor plugin.
Loading and Rendering .koolmap
– GDScript Example
Godot 4 provides the FileAccess
API for reading files. We can use it to open our .koolmap
and read data similarly to our Python example. Once we have the map data, we’ll use Godot’s TileMap or Node2D structure to render it.
Steps to load map in GDScript:
- Read the file: Use
FileAccess
(or the higher-level Resource if we made a custom resource). GDScript example:
func load_koolmap(path: String) -> TileMap:
var file = FileAccess.open(path, FileAccess.READ)
if file == null:
push_error("Failed to open map file")
return null
# Read header
var magic = file.get_buffer(7) # 7 bytes "KOOLMAP"
if magic.get_string_from_ascii() != "KOOLMAP":
push_error("Not a valid KOOLMAP file")
return null
file.get_8() # skip null terminator or alignment
var version = file.get_32() # 32-bit version
var width = file.get_32()
var height = file.get_32()
var block_w = file.get_16()
var block_h = file.get_16()
var total_blocks = file.get_32()
var comp_method = file.get_8()
var enc_flag = file.get_8()
var reserved = file.get_16()
var index_offset = file.get_32()
var index_count = file.get_32()
var index_entry_size = file.get_32()
# Read index entries
file.seek(index_offset)
var index = []
for i in range(index_count):
var off = file.get_64()
var length = file.get_32()
index.append([off, length])
# Create a TileMap node to hold the map
var tilemap = TileMap.new()
tilemap.tile_set = preload("res://world_tileset.tres") # tileset resource with tile textures
tilemap.tile_origin = TileMap.TILE_ORIGIN_BOTTOM_LEFT # for isometric, usually bottom left origin
tilemap.cell_orientation = TileMap.CELL_ORIENT_ISOMETRIC # or ORIENT_RECT for top-down
tilemap.cell_size = Vector2( block_w * 32, block_h * 16 )
# example: if each tile is 32x16 px (2:1 ratio diamonds), adjust accordingly
(Explanation:) We open the file, read the header fields (using appropriate get_8
, get_16
, etc., which read little-endian by default). Then we seek to the index and read all entries into a GDScript array. We then create a Godot TileMap
node. We assume we have a TileSet
resource (world_tileset.tres
) already configured with our terrain and maybe static tile images. Setting cell_orientation
to ISOMETRIC
tells Godot that the tiles are in an isometric diamond layout (which affects how it positions them). tile_origin = BOTTOM_LEFT
ensures that the bottom of the diamond tile is the “anchor” point (common for isometric so tiles stack vertically correctly). cell_size
we set roughly to the pixel dimensions of one tile cell; for example, if our base tiles are 64×32 px diamonds, cell_size would be (64, 32). Here I put 32×16 just as a placeholder for demonstration.
- Iterate through blocks and set tiles: We can either set tiles cell by cell using
TileMap.set_cell()
, or useTileMap.set_cell_tile_data
if wanting advanced layers. Simpler: loop over each block, then each cell in block, placing the tile. For isometric, Godot expects the TileMap’s cell coordinates such that (x,y) correspond to isometric grid coords.
# Place terrain tiles
var blocks_x = width / block_w
var blocks_y = height / block_h
for block_id in range(index_count):
var off = index[block_id][0]
var length = index[block_id][1]
if length == 0:
continue # empty block
file.seek(off)
var data = file.get_buffer(length)
if comp_method == 1:
data = data.decompress(DECOMPRESS_ZLIB)
# Compute block's top-left tile coordinate in map
var bx = block_id / blocks_y # integer division
var by = block_id % blocks_y
var base_tile_x = bx * block_w
var base_tile_y = by * block_h
# Terrain cells:
for cy in range(block_h):
for cx in range(block_w):
var index_in_block = cy * block_w + cx
var tid = data.get16(index_in_block * 3) # read uint16
var alt = data.get8(index_in_block * 3 + 2) # read int8
# Use tid as Tile ID in the TileSet (assuming one-to-one mapping)
if tid != 0xFFFF:
tilemap.set_cell(base_tile_x + cx, base_tile_y + cy, tid, false, false)
# In isometric, Y-sort will handle altitude in rendering, see below
(Explanation:) We calculate the block’s position: if blocks are counted with X fast or Y fast, we adjust accordingly. In the above, I assumed block ID formula as in UO: block_id = (x_block * blocks_y) + y_block, so x_block = block_id / blocks_y, y_block = block_id mod blocks_y. Then base_tile_x = x_block * block_width, base_tile_y = y_block * block_height. We then iterate cells within, get the tile ID and altitude. We call TileMap.set_cell(x, y, tile_id)
to place that terrain tile from our TileSet. The false, false
in set_cell
are for flip_x
, flip_y
(we’re not flipping). We ignore altitude at this moment for tile placement, because Godot’s TileMap doesn’t have an altitude for a cell – instead, we will handle elevation by Y-sorting or multiple layers.
-
Place static objects: For statics (trees, buildings, etc.), there are multiple ways. We could treat statics as part of the TileMap as well – Godot 4 allows multiple layers in one TileMap. For example, the new TileMap system lets each cell have a stack of tiles (with different z-index values). However, it might be simpler to instantiate them as individual sprites or use a separate TileMap layer for statics. Using individual Node2D (Sprite2D) nodes for each static might be heavy if thousands of objects, but Godot can handle quite a few if well batched. Alternatively, we create another TileMap solely for statics, with transparency in its tiles.
Godot 4’s TileMap supports Layers internally (TileMap has a property
tilemap.get_layer_count()
and you can set tiles with a layer index). We could exploit that: layer 0 = terrain, layer 1 = statics. Each layer can have a different Z index offset. If using that, we can still useset_cell(x,y,tile, layer_index)
.
For clarity, let’s assume we do a separate TileMap node for statics for now:
var statics_node = TileMap.new()
statics_node.tile_set = preload("res://statics_tileset.tres")
statics_node.tile_origin = TileMap.TILE_ORIGIN_BOTTOM_LEFT
statics_node.cell_orientation = TileMap.CELL_ORIENT_ISOMETRIC
statics_node.cell_size = tilemap.cell_size # same base size
statics_node.z_index = 1 # put statics above ground layer
statics_node.y_sort_mode = TileMap.YSORT_INDEX # (in Godot 4.1+, allows Y-sorting by cell)
add_child(tilemap)
add_child(statics_node)
# ... continuing the loop above:
# still inside block loop after placing terrain
# Now parse static objects from data
var statics_offset = block_w * block_h * 3
var pos = statics_offset
while pos < data.size():
var obj_id = data.get16(pos)
if obj_id == 0xFFFF:
break
var sx = data.get8(pos+2)
var sy = data.get8(pos+3)
var salt = data.get8(pos+4) # signed altitude
# Place this static: compute world coords
var wx = base_tile_x + sx
var wy = base_tile_y + sy
# Option 1: using TileMap layer for statics
statics_node.set_cell(wx, wy, obj_id, false, false)
# Godot will treat each cell as one tile from statics_tileset
# The statics_tileset should contain the sprite (with proper texture region and offset)
# Optionally, one could offset it vertically by salt if needed via tileset.
pos += 7
We create statics_node
as another TileMap with the statics tileset (which contains all item graphics). We set its z_index = 1
so that it renders on top of the terrain tilemap (z_index = 0
by default for ground). We also enable y_sort_mode = YSORT_INDEX
for the statics TileMap, which (in Godot 4.0+) is supposed to help sort tiles by their cell positions. In Godot 4, there is also a concept of Z as relative
and using Y-sorting in 2D. We might need to ensure that for isometric, Z as relative
is on, meaning the node’s own Z_index adds to children. The specifics can get tricky; we might instead rely on the fact that if we separate into two tilemaps and then use a YSort node, we need to manage sorting differently (since TileMap draws all its tiles presumably in correct order itself if YSORT is enabled).
Elevation and Draw Order: In isometric games, draw order typically follows a rule: objects with a higher Y (farther “down” the screen or lower in world coordinates) are drawn above objects behind them, and if the same Y, then higher altitude is drawn above. In Godot, we have a couple of mechanisms:
- YSort node: Godot provides a
YSort
node which, if you place Sprite or Node2D children under it, will automatically sort their rendering order by their Y coordinate (lower Y gets drawn later = appears on top). This is good for independent sprites like characters and items if they are separate nodes. - TileMap Y-sorting: Godot 4’s TileMap has a property to enable Y-sort for its tiles (so it will order tile drawing by tile coordinates). However, mixing TileMap and individual nodes can complicate who draws first. One approach is to not use TileMap for statics at all, but instead spawn each static as an
Sprite2D
(with the static texture) as a child of a YSort node. This gives fine control: each sprite’s position is (world_x, world_y – salt* some_pixel) to account for altitude, and YSort will handle the sorting. The downside is having thousands of nodes (though Godot can handle quite a few if they are just static sprites).
Given that performance might still be okay with thousands of static sprites (especially if using batching via same texture atlas), this approach is quite straightforward in concept.
Let’s outline a hybrid approach:
- Use a TileMap for the base terrain (which is static and simple).
- Use a YSort node containing individual Sprite2D for statics and moving entities, to leverage Godot’s sorting.
This way, the TileMap draws all ground tiles first (z_index 0), then the YSort ensures all sprites (statics and characters) are drawn in proper order on top.
Applying elevation (z-index or Y-sort): If we have a static that sits on the ground but tall, we want it drawn over things behind it. YSort by their Y coordinate naturally handles overlap when one object is behind (higher y means further back typically). Altitude (salt) means the object is elevated (like on a roof). We can incorporate altitude by adjusting the sprite’s Y coordinate or using an extra Z-index. One trick: We could subtract altitude from the sprite’s y-position when adding to YSort, so that it appears “higher” for sorting (which actually might cause it to be drawn under things it should). Actually, if altitude means the object is higher off ground (like a second-floor object), it might not need special treatment in 2D ordering – it just needs to visually be drawn above ground objects anyway because it will likely have a higher y coordinate in world? Consider a multi-story building: the second floor’s items have the same map x,y but higher alt. In a purely 2D sort by y, the floor above might have the same projection position as ground floor, causing z-fighting in draw order. To solve that, one could give those higher-altitude objects a slightly lower Y in their actual Node position (so they render behind ground ones at same x,y) – but that’s counter-intuitive. Alternatively, use the z_index
property on those sprites: e.g., for every 10 altitude units, increment z_index by 1. Then YSort can be combined with Z index: Godot sorts by Z first, then Y if Z equal. If we set second-floor objects to z_index = 1 while ground floor are 0, then all ground (z0) draw, then all second-floor (z1) draw, and within each, YSort orders them.
So a rule could be: sprite.z_index = altitude_level
(like altitude divided by some constant, or just the altitude if small). And ensure YSort
is on, which sorts within each z_index group.
In code, for each static we could do:
var sprite = Sprite2D.new()
sprite.texture = StaticTextures.get_texture(obj_id) # some method to get the right texture region
sprite.position = MapToPixel(wx, wy, salt) # convert tile coords to screen coords, including altitude offset
sprite.z_index = int(salt) # treat each altitude unit as one layer for simplicity
sprite.z_as_relative = true # ensure the YSort parent considers this
y_sort_node.add_child(sprite)
The MapToPixel
function would convert tile grid coordinates to actual pixel positions. In isometric:
- If each tile is W wide and H tall (e.g., 64×32 px), then an tile coordinate (x,y) might map to pixel ( (x – y) * (W/2), (x + y) * (H/2) ) as a typical formula for diamond iso. Altitude could be represented by subtracting
salt * elevation_pixel_step
from the y coordinate (since up in 3D means visually should be drawn slightly higher on screen). In UO, each altitude unit corresponds to 4 pixels in the isometric projection for drawing (they used formulas like DrawY = … – Alt*4). We could adopt 1 altitude = 4 px vertical shift (assuming our art is scaled similarly). - So
MapToPixel(wx,wy,salt)
might compute base pixel (iso_x, iso_y) and then iso_y -= salt * 4. This will make higher items appear visually elevated.
By doing that, the sprite representing a second-floor item will be drawn above ground floor items not by draw order, but by literal position difference (it will be slightly higher on the screen). Draw order sorting (YSort) will then see it at a smaller Y coordinate (since higher up means lower Y value on screen) and thus draw it later (on top). This nicely matches real isometric depth sorting: an object that is “higher” in altitude is drawn later because it appears above those on the ground behind it.
This technique – adjusting screen position by altitude – is key to proper layering. So either we incorporate it by offsetting sprite positions, or by having the tile’s texture offset (like in the TileSet, give tiles a negative vertical offset equal to 4*altitude). If using TileMap for statics, you can’t easily vary per cell offset except by designing separate tiles for each altitude or some data. So individual sprites is easier for altitude variation.
Mobile considerations: Godot on mobile (Android/iOS) has the same capabilities but weaker hardware. We need to ensure our approach is optimized:
- Batching: Using TileMap is very efficient because it batches all tiles in one draw call per layer. Thousands of ground tiles – no problem. For statics, if we go with individual sprites, each sprite could add a draw call unless they share a texture atlas. We should therefore pack all static sprites into one or a few atlases (Godot’s TileSet or theme can do this, or manually via AtlasTexture). If we use a TileMap for statics, it will batch automatically, but as mentioned handling altitude is trickier. However, Godot 4’s TileMap does allow custom data per tile, which potentially could include altitude info used in a shader, but not trivial.
- Memory: the map file might be large; on mobile we might not load the entire map at once. We can load chunks around the player. With our format, we can implement region-based loading: for example, only load blocks within some radius of the player’s position. This means keeping file access available or loading from a smaller representation. We could memory-map the file (not sure if Godot’s FileAccess can do partial loads asynchronously; we might have to load the whole file or do manual chunk reads).
- One could conceive of a streaming approach in Godot: As the player moves, determine which blocks entered view and load them, instantiate their sprites, and remove far-off ones. This would be an advanced usage, but
.koolmap
format supports it because of random access. - On mobile GPUs, overdraw (drawing a lot of overlapping alpha-blended sprites) can hurt performance. UO’s isometric view inherently has overlapping objects. One mitigation is to limit the view range (don’t draw everything, just what camera sees). Another is using tilemaps (which can use opaque tile rendering for terrain and only sprites for objects).
- We should ensure texture sizes aren’t too big for mobile: e.g., avoid a single atlas bigger than 2048×2048 if some older devices can’t handle it (though most can handle 4096×4096 nowadays). It’s fine to have multiple atlases.
Godot C# Integration Example
If using C# in Godot (via Mono), the approach is similar but using C# file IO or Godot’s C# API. Godot’s C# API provides FileAccess
as well (via Godot.FileAccess
). We can use it similarly to GDScript but in C# style:
using Godot;
using System;
using System.IO;
using System.IO.Compression; // for DeflateStream if needed
public partial class MapLoader : Node
{
public TileMap LoadKoolMap(string path)
{
var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
if (file == null) {
GD.PushError("Cannot open map file");
return null;
}
string magic = file.GetBuffer(7).GetStringFromAscii();
file.Get8(); // skip terminator
uint version = file.Get32();
uint width = file.Get32();
uint height = file.Get32();
ushort blockW = (ushort)file.Get16();
ushort blockH = (ushort)file.Get16();
uint totalBlocks = file.Get32();
byte comp = (byte)file.Get8();
byte enc = (byte)file.Get8();
ushort resFlags = (ushort)file.Get16();
uint indexOff = file.Get32();
uint indexCount = file.Get32();
uint indexEntrySize = file.Get32();
file.Seek(indexOff);
var offsets = new ulong[indexCount];
var lengths = new uint[indexCount];
for(uint i=0; i<indexCount; ++i) {
offsets[i] = file.Get64();
lengths[i] = file.Get32();
}
// Create TileMap and YSort as in GDScript example...
TileMap ground = new TileMap();
ground.TileSet = GD.Load<TileSet>("res://world_tileset.tres");
ground.CellOrientation = TileMap.CellOrientation.Isometric;
ground.TileOrigin = TileMap.TileOrigin.BottomLeft;
ground.CellSize = new Vector2I(blockW * 32, blockH * 16);
TileMap staticsMap = new TileMap();
staticsMap.TileSet = GD.Load<TileSet>("res://statics_tileset.tres");
staticsMap.CellOrientation = TileMap.CellOrientation.Isometric;
staticsMap.TileOrigin = TileMap.TileOrigin.BottomLeft;
staticsMap.CellSize = ground.CellSize;
staticsMap.ZIndex = 1;
staticsMap.YSortMode = TileMap.TileMapYSortMode.YSortIndex;
// Alternatively use YSort node for statics:
YSort ysort = new YSort();
ysort.AddChild(ground);
ysort.AddChild(staticsMap);
// (If using individual sprites, you'd add them to ysort instead of staticsMap.)
AddChild(ysort);
// Loop through blocks to place tiles
int blocksX = (int)(width / blockW);
int blocksY = (int)(height / blockH);
for (uint blockId = 0; blockId < indexCount; ++blockId) {
if (lengths[blockId] == 0) continue;
file.Seek((long)offsets[blockId]);
var compData = file.GetBuffer((long)lengths[blockId]);
byte[] rawData;
if (comp == 1) {
// decompress zlib (deflate). Godot's GetBuffer might give zlib data directly.
// .NET can decompress with DeflateStream if we trim zlib header if present.
using (var ms = new MemoryStream(compData)) {
using(var ds = new DeflateStream(ms, CompressionMode.Decompress)) {
using(var outMs = new MemoryStream()) {
ds.CopyTo(outMs);
rawData = outMs.ToArray();
}
}
}
} else {
rawData = compData;
}
int xBlock = (int)(blockId / blocksY);
int yBlock = (int)(blockId % blocksY);
int baseX = xBlock * blockW;
int baseY = yBlock * blockH;
// Terrain tiles:
int cellCount = blockW * blockH;
for(int i = 0; i < cellCount; ++i) {
ushort tid = BitConverter.ToUInt16(rawData, i*3);
sbyte alt = (sbyte)rawData[i*3 + 2];
int cx = i % blockW;
int cy = i / blockW;
if (tid != 0xFFFF) {
ground.SetCell(new Vector2I(baseX + cx, baseY + cy), (int)tid);
}
}
// Statics:
int pos = cellCount * 3;
while(pos + 7 <= rawData.Length) {
ushort objId = BitConverter.ToUInt16(rawData, pos);
if (objId == 0xFFFF) break;
byte sx = rawData[pos+2];
byte sy = rawData[pos+3];
sbyte salt = (sbyte)rawData[pos+4];
// If using staticsMap TileMap:
staticsMap.SetCell(new Vector2I(baseX + sx, baseY + sy), objId);
// Option: could use multiple layers in staticsMap if needed.
// If using individual sprites:
// var sprite = new Sprite2D();
// sprite.Texture = StaticAtlas.GetRegionTexture(objId);
// sprite.Position = iso_map_to_screen(baseX+sx, baseY+sy, salt);
// sprite.ZIndex = salt;
// ysort.AddChild(sprite);
pos += 7;
}
}
return ground; // or return a Node that contains both ground and statics
}
}
This C# code is longer, but closely follows the GDScript logic. It uses Godot’s C# FileAccess
to read data. Notably, decompressing might require handling the zlib header; the example uses DeflateStream
which expects raw DEFLATE data (zlib uses an extra 2-byte header and checksum). We might need to skip the first 2 bytes of compData
for proper deflate. But for brevity, assume it works.
The code then sets up TileMap nodes and uses SetCell
similarly. In C#, TileMap.SetCell(Vector2I pos, int tileId, bool flipX=false, bool flipY=false, bool transpose=false)
would be used (the API might be slightly different, but conceptually the same).
Godot Isometric Rendering & Sorting Details:
- We set
CellOrientation.Isometric
which tells Godot to use an isometric transform (which assumes a 2:1 ratio diamond shape). - We set
TileOrigin.BottomLeft
so that the tile texture’s bottom-left corner corresponds to the grid point. For a typical diamond tile, bottom center might also be used; but bottom-left often works if the tile is shaped correctly in the atlas. - To properly layer tall objects, we might rely on the engine’s YSort if using sprites. If using tilemaps exclusively, Godot will draw tilemap layers in order of their z_index and within a layer probably in row order (unless YSortIndex is used as we set on staticsMap). The
YSortIndex
mode for TileMap in Godot 4.0+ sorts tiles by their cell index (which presumably tries to simulate Y sorting). However, some users have reported needing to use multiple tilemaps or manually adjusting draw order in complex scenarios. - Another approach is to give each TileMap (ground and statics) a different
ZIndex
and also setZAsRelative = true
on the statics map, so that its z_index is considered relative to parent (which is a YSort maybe). Honestly, a simpler robust approach is indeed using one YSort node with individual child nodes for each drawn element. That’s what many isometric projects in Godot do for dynamic scenes. For a mostly static world, tilemap is fine for ground.
Given we have the data, one can experiment with both methods. The correct rendering order in a fully dynamic environment often resorts to YSort with individual nodes. But since the question specifically mentions apply elevation with z-index or Y-sorting, we have explained both concepts:
- z-index method: assign higher z-index to higher-altitude items so they render later.
- Y-sorting method: use YSort or equivalent so that lower screen Y (which corresponds to objects further “south” or closer to camera in iso) are drawn on top.
These are not mutually exclusive – often used together. For example, in Godot one might do sprite.z_index = altitudeLevel; sprite.z_as_relative = true
and place it under a YSort. This means: first sort by z_index (so altitude layers), then by Y within each altitude.
We should also mention Godot’s handling of transparency sorting issues. By default, Godot can sort by Y or Z, but if tiles overlap within the same layer, sometimes you get sorting issues at tile boundaries (common in isometric tilemaps – e.g., a tall wall tile might overlap a neighbor incorrectly). Enabling YSort or splitting into layers helps avoid that. Also, because our statics are separate, a tall static (like a tree) on one tile will just be drawn after ground behind it if YSort is used.
Region metadata in Godot: Once we have region info from the file (which we haven’t parsed above, but suppose we did and have a list of regions and their areas), we can use it in the game. For example, if the player’s coordinates correspond to a region marked “GuardZone”, the client could display “You are in a guard-protected area” or play particular music. Implementation:
- After loading the map, create a Dictionary or Map of region ID -> region properties (name, etc.), and perhaps a 2D array or quadtree to look up region by coordinates.
- In the game script (likely in the player or world node), each time the player moves, compute which region tile they are on and check if it’s different from last region. If changed, trigger events (like change music theme if entering a dungeon region).
- Regions could also be used for environment effects – e.g., a region marked “swamp” might tint the screen or slow the player, etc.
- If integrated in Godot, one could attach Area2D nodes for regions as triggers if regions are rectangular. For instance, create an Area2D with a CollisionShape covering the region and connect to body_entered signal for the player – but generating those automatically from region definitions might be a bit complex, and a simple coordinate check might suffice.
Terrain metadata usage: The .kooltile
(tile properties) can be loaded as a resource or scriptable data. In Godot, you could load a JSON of tile definitions on game start. Then:
- If a tile has “Impassable” flag, you’d mark that cell in the TileMap as non-walkable. Godot’s TileMap in 4.x supports navigation layers or collision shapes on tiles; we could define collisions in the TileSet (e.g., a rock tile has a collision polygon so player can’t walk through).
- Water tiles (Wet flag) might trigger swimming or dismounting – the client could check tile flag and implement logic (or simply allow the server to enforce movement rules).
- Light source flags could be used to spawn light nodes in Godot at that tile position.
- “Damage” tiles (like lava) could be Area2D that cause damage if stepped on, etc. The client could either just visually represent and let server handle actual damage, or if single-player environment, handle it directly.
The key point is the new format preserves these possibilities by carrying over the concept of tile flags and region data, enabling the same gameplay logic to be implemented in the new engine. It’s about providing hooks; the rest is up to game scripts.
Mobile-friendly considerations:
- Use of YSort with many children on mobile could be a bit heavy if not using multi-mesh. If performance is an issue, consider chunking the scene: for example, only instantiating statics in currently visible screen area, and removing them when out of view (since map is large).
- Godot 4 has
VisibilityNotifier2D
which can signal when something exits the screen; one could attach that to a chunk grouping to unload offscreen parts. - Also consider lowering texture resolution or using compressed textures (Godot supports ETC compression for mobile).
- Keep an eye on fill rate: if there are large overlapping translucency (many trees overlapping), it can slow down mobile GPUs. Perhaps limit the number of layers or use some baking (not easy for dynamic but possibly static).
- If the entire world is static (like UO’s world mostly is except moving characters), an alternative approach on mobile is to pre-render parts of the map into image textures (“lightmaps” or chunks of map rendered to a single sprite) to reduce draw calls. But that’s an advanced optimization and reduces flexibility (and memory heavy). With our chunk system, one could generate mini-maps or precombined tiles for distant scenery.
Overall, Godot 4.4.1 should be capable of running a UO-like world with these methods. We’ve shown how to load the data and create scene nodes. The elevation sorting is handled via a combination of YSort or careful z_indexing. There may be some tweaking required (Godot’s 2D rendering can be finicky with isometric sorting), but many developers have done isometric games in Godot using YSort successfully.
7. Godot Editor Plugin for .koolmap (In-Editor World Editing)
To empower content creators, we envision a Godot Editor Plugin that allows viewing and editing .koolmap
files within the Godot Editor. Godot’s EditorPlugin API lets us extend the editor with custom dock panels, importers, etc. The idea is to provide a tile-based world editor akin to UO’s internal tools (or modern map editors), but using Godot’s interface.
Plugin Overview:
- The plugin would register a custom EditorInspector or EditorSpatial (actually 2D) for
.koolmap
files. Possibly, we treat.koolmap
as a custom Resource type. We could create a classKoolMapResource
in GDScript or C# that knows how to load/save the file (using the code we wrote above). We then tell Godot’s editor that.koolmap
extension corresponds toKoolMapResource
. Godot can then allow importing or opening it. - The plugin might provide an EditorDock (a panel on the side) with tools: tile palette, object palette, selection tools, etc.
- We would use Godot’s existing TileMap editor as much as possible – since Godot already has a GUI for painting tiles on a TileMap node. One approach: when the user opens a
.koolmap
, the plugin creates a temporary scene with a TileMap (for terrain) and another for statics (or a YSort with sprites). It then populates these from the file. Then the user can visually edit: Godot’s tilemap editor allows painting tiles from the TileSet. We might need to also allow placing statics (which could be done by switching to the statics TileMap’s editing mode). - Alternatively, implement custom painting logic: e.g., clicking in the 2D view to place a tile or object.
The plugin should support:
- Terrain painting: User selects a terrain tile from a palette (list of grass, dirt, water, etc.) and paints on the map. Under the hood, we set those cell IDs in the TileMap and mark the chunk as modified.
- Object placement: User can select an object (tree, building piece) and click on the map to place it. That might create a sprite or add a cell in the statics layer.
- Elevation adjustment: Perhaps a tool to raise/lower altitude of a tile or object. For terrain, maybe right-click or a separate brush to increase altitude (we could have a mode to edit the altitude byte of cells by numeric input or dragging). For statics, one might click an object and input an altitude or use arrow keys to nudge it up/down (in UO, statics like multi floors often have altitude differences).
- Selection and deletion: Select an area or specific object to remove or move. E.g., a marquee tool to select multiple tiles/objects (which likely means we need to handle statics separately from terrain for movement).
- Region editing: Possibly a way to define named regions on the map – e.g., draw a rectangle or polygon that becomes a region with certain properties. This could be a side panel listing all regions, allowing editing their boundaries and flags. We might represent region bounds in the editor view as colored overlays or wireframe boxes.
Godot’s editor is very flexible, but building a full map editor is a project in itself. However, since the question asks for an outline, let’s highlight key components:
- Palette UI: Could be a simple scrollable list of tile thumbnails. We can generate thumbnails for each tile ID from the tileset (Godot can give us the TileSet’s tiles). Similarly for statics, which might be too many to list at once – perhaps categorize by type (trees, walls, etc.). Could use tabs or filters.
- Tools: Brush (paint one tile at a time), Rectangle fill, Flood fill, Eraser (set tile to empty or remove static), Elevation adjust (maybe a number input + brush to apply that altitude).
- Preview: Show a preview of the currently selected tile under the cursor so user sees what they’ll place.
Saving/Exporting: The plugin after editing will need to save changes back to .koolmap
format. Because our runtime code can write .koolmap
(as described earlier), we can reuse that. For instance, the plugin can maintain a data structure of modified chunks. When the user hits “Save”, we reconstruct the .koolmap
:
- For each chunk, gather the current terrain cells and static objects from the TileMap nodes in the editor scene, compress as needed, update index, and write the file. Or we could rewrite the entire file (since in-editor we can afford that).
- Alternatively, we could live-update the binary file for each chunk as it’s edited, but that’s complex and unnecessary given Save is fine.
Godot integration details: We likely create a new class extending EditorPlugin
in GDScript or C#. In _enter_tree()
of the plugin, we might register a new menu item “New KoolMap” or make the importer. Also, possibly create a custom EditorSceneImporter
so that if someone drags a .koolmap
file into Godot, the plugin knows how to import it (maybe converting it to a TileMap scene or a KoolMapResource
).
One could also integrate with Godot’s TileSet editor to manage the art: the art is likely going to be part of a Godot TileSet resource for convenience. We might have an external tool to generate the world_tileset.tres
from .koolart
images, or the plugin can load .koolart
file and produce a TileSet on the fly. Possibly simpler: require the modder to import all the tile textures into Godot and create a TileSet manually. But a slick plugin could automate that by reading .koolart
(if it’s a known archive format like a folder of images or a single atlas with mapping) and constructing a TileSet.
User workflow:
- The modder opens Godot, uses our plugin to open an existing
.koolmap
or create a new one. - If new, they specify dimensions, chunk size, and maybe choose an existing TileSet or have the plugin prompt for images.
- The world appears as a grid they can paint on. They design the terrain, place objects, define regions.
- They click Save, the plugin writes out
mymap.koolmap
,mymap.koolmap.meta
(Godot might require a .import or .meta file too if treated as import). - The modder can then run the game which loads
mymap.koolmap
at runtime to play.
This in-editor approach streamlines world building, as opposed to editing via external tools like CentrED or UOFiddler (which were used for UO). It leverages Godot’s strength as both an engine and an editor.
Challenges:
- Ensuring the edited map stays consistent with external references (if any). For example, if the user deletes a static object that the server expected to be there (maybe not an issue if server also uses
.koolmap
to spawn things). - We must also allow adding new tile types or object types. If
.koolart
is separate, adding a new art means updating the art file and the TileSet. The plugin could offer an “Import image as new tile” feature, which adds it to the TileSet and assigns a new ID, updates.koolart
accordingly. This requires the plugin to handle art content management as well. - Perhaps in early stages, it’s easier to manage art outside (e.g., prepare your TileSet in Godot manually, then just use those IDs in the map). But eventually a full solution ties it together.
Testing and preview: Because Godot allows running the game in editor, the plugin could even allow a play-test mode where the user can drop a player character in and walk around immediately on the edited map.
In conclusion, the Godot editor plugin acts as a bridge between the open format and a user-friendly editing environment. By using Godot’s own editing capabilities (tile painting, scene editing), we minimize reinventing the wheel. The plugin’s main job is loading/saving .koolmap
and possibly managing the asset pipeline (.koolart to TileSet, etc.).
8. Tools and Utilities
Beyond the Godot plugin, several external tools and utilities will support the .kool
format ecosystem:
-
Conversion Tools: High on the list is a
.mul
to.kool
converter (and vice versa). This command-line tool (or GUI) would take an UO installation’s files and produce corresponding.koolmap
,.koolart
,.koolsnd
, etc. For example:mul2kool --map map0.mul --statics statics0.mul --staidx staidx0.mul --out myworld.koolmap
would combine the classic map into one.koolmap
. It would iterate UO map blocks, read terrain and statics, and then output using our chunked format. This helps bootstrap the new format with existing content (for testing purposes, assuming the user legally has the UO files). Even though legally we can’t distribute the output, a user could convert their own files for personal use or for developing the client before replacements are ready.- Similarly,
mul2kool --art art.mul --artidx artidx.mul --out assets.koolart
to pack all art. Actually,.koolart
could just be a collection of PNGs, but if we define a binary, the tool will create that. mul2kool --tiledata tiledata.mul --out base.kooltile
to convert tile flags to our JSON/txt format.- The reverse,
kool2mul
, could allow using the new content in legacy systems (for instance, injecting new map into ClassicUO by generating .mul files). This might be less needed once ClassicUO can read.kool
directly, but it’s a nice option.
Such converters can be written in Python (since parsing both formats is doable with struct as shown) or in C# leveraging existing UO file readers (ServUO, ClassicUO code could be reused since they have classes for .mul files).
-
Tile/Art editors: Tools like UOFiddler allowed viewing and editing UO art and textures. For
.koolart
, since it’s open (likely images or an atlas), one can use any image editor to create sprites. But having a specialized viewer to browse all tiles and items is convenient. We could adapt an existing tool or use Godot itself. Possibly, the Godot plugin’s palette serves as an art viewer already. Alternatively, a small Qt or web-based viewer that loads.koolart
and displays sprite sheets might be made. -
World viewers: A lightweight map viewer (like an online map) could be made. For example, a script to draw the entire map to a large PNG (like UOAutomap style or UO Cartographer). With
.koolmap
it’s easier since one can read all tiles and paint to an image (even PIL in Python to draw colored pixels or mini textures). This is helpful for validation and for players (if you want to provide a world atlas). -
Server utilities: If ServUO or another server is used, tools to sync or update server data might be needed:
- A utility to import
.kooltile
flags into the server’s item definitions (or generate ServUO scripts from them). - If regions are defined in
.koolmap
, a tool to export those to ServUO’s region configuration (unless we modify ServUO to read.koolmap
directly, which would be ideal). - If using encryption, a key generator tool for servers to produce keys and embed in client distribution.
- A utility to import
-
Data validation tools: Programs to verify the integrity of
.kool
files. For example:koolcheck myworld.koolmap
could verify that index offsets and lengths are correct, that no chunk is corrupted (maybe recompute chunk CRCs if stored), etc. It could also cross-verify references: e.g., a static object ID in map is within range of the art file entries; a tile ID has a corresponding tiledata entry; region IDs are consistent.- Also a tool to list contents: e.g., output how many of each tile appear on the map, or list unknown IDs.
-
Performance tools: Possibly a converter to optimize chunk sizes. For instance, maybe after a lot of edits, chunks might be unevenly sized (some very large due to lots of statics). A tool could split very large chunks or merge if needed (though we fixed chunk as an 8×8 area – probably keep that constant). Or recompress chunks with a different algorithm (if in future we support multiple compression schemes, one might want to recompress all using a better method if engine supports it).
-
Integration with existing modding tools: There’s a community of modders with various tools. We could create adapters for some. For example, if someone wants to use the Tiled map editor (a popular general tilemap editor) to design a map and then export to
.koolmap
: we could write a Tiled plugin or conversion script. Tiled supports isometric maps and can export to JSON or custom XML; a script could convert that output to.koolmap
binary. -
Documentation generator: Perhaps a script that generates documentation from the format spec (like creating HTML or markdown tables of the file structure for reference). But given we will maintain docs manually, this is minor.
In essence, a healthy set of utilities will make the .kool
format practical and encourage adoption. Many of these would likely be open-source projects on GitHub for collaboration.
One likely scenario is leveraging the ServUO/ClassicUO communities: ClassicUO being open, if we implement .kool
support in it, ClassicUO’s built-in developer tools (like it has a debug gump, I think) could even assist. But external converters are simpler to start with.
9. Comparisons to Other Modding Ecosystems
The idea of overhauling a game’s asset format and replacing all content has precedents. We can draw lessons from classic modding communities like those of Doom and Quake:
-
Doom WAD: As mentioned, Doom’s data is packed in WAD files (Where’s All the Data) which contain labeled lumps. The Doom community created Freedoom, a free asset set under BSD license, to allow Doom engine to run standalone. This is analogous to what we aim for: providing free content for an open client. The WAD format itself taught us the value of simplicity and documentation – id Software published specs (or rather fans did quickly) and supported modding from day one. Our
.kool
should strive to be similarly easy to mod. WAD’s lumps are sequential with a central directory at the start (or end). We similarly have an index and sequential chunks. One difference: WAD lumps are identified by names (like “MAP01” or “DSPISTOL” for pistol sound). We could consider naming chunks or sections in.kool
for clarity (like naming regions or multi templates), but since UO was mostly ID-based, we stick to IDs.- Another aspect: Doom distinguished IWAD (core game WAD) vs PWAD (patch WAD). The concept of layering mods is relevant: we might allow
.koolmap
to have an override file (like a small file that replaces some chunks or adds statics for an event). This could be akin to UO’s diff files or verdata. Or akin to how you can load multiple WADs (the later ones override content). For our format, implementing patch/override could be done via a small.koolpatch
file containing just a few chunks or art pieces with references to base file. This is something to consider for flexibility (e.g., a server could distribute a tiny patch file to change a few tiles seasonally, instead of forcing re-download of entire map). - The Doom mod community also standardized on open documentation and had tools like DEU (Doom Editor Utilities) very early. They benefited from the game being open for modding. We similarly need to ensure tools are available and the format is well-known.
- Another aspect: Doom distinguished IWAD (core game WAD) vs PWAD (patch WAD). The concept of layering mods is relevant: we might allow
-
Quake PAK: Quake (1996) used .pak files, which are essentially uncompressed archive files containing a directory of internal file names (like textures, maps, models). A PAK is simpler than WAD (no distinction of types, just files in a package). This is similar to a zip file but with a custom header and no compression by default. The Quake community quickly made tools like PakScape to inspect and edit .pak files. The takeaway for us: using a standard or easily parseable container lowers the barrier for tool creation. Our
.kool
chooses a custom binary layout to maximize runtime efficiency for the specific data (tile maps, etc.), rather than a generic archive. This is fine, but we could incorporate some archive-like features (like flexible adding of new subfiles such as region definitions).- Quake modding eventually moved to .pk3 (which is just a renamed .zip file) for Quake III Arena. Many modern games use zip or other standard archives (like .pak in Unreal Engine 4 is actually a custom but somewhat similar approach). Using a standard like zip could have been an option for
.kool
(for example, putting map chunks as files in a zip). We went with a custom approach to mimic .mul conversion, but if we foresee lots of independent files, adopting zip could be considered. However, zip would compress whole files and not be great for partial updates (unless we store each chunk as a file inside zip, which is doable – effectively an index and compressed data, which is not far from what we have). - Another lesson: file naming in archives. PAK had names, which meant mods could replace a specific file by including one with same path in a higher-priority PAK. In UO land, replacements were done by patches (verdata or diff files). We might allow multiple
.koolmap
layers (like base + patch). This is more of a software design detail, not necessarily in the file format (it could be simply the client checks for an override file).
- Quake modding eventually moved to .pk3 (which is just a renamed .zip file) for Quake III Arena. Many modern games use zip or other standard archives (like .pak in Unreal Engine 4 is actually a custom but somewhat similar approach). Using a standard like zip could have been an option for
-
Modern formats (e.g., Bethesda’s ES/ESP or TES3 Morrowind plugins, or Blizzard’s MPQs): Many games have data formats that fans reverse-engineered to create mods. Often they ended up writing extensive wikis or open documentation, which helped their longevity. We should maintain an official spec document (and maybe even code library) to encourage contributions.
-
Freely licensed content: Freedoom’s model of using BSD license for art, etc., is interesting. Many open game projects use Creative Commons for assets (e.g., CC-BY or CC0). We should decide on a license for our content and format spec. Likely, the format specification can be CC0 (public domain) or a very permissive license, so anyone can implement it. The code we write (converters, etc.) can be MIT or BSD. The assets we produce (art, sound) might be CC-BY-SA or CC-BY (to encourage sharing but also protecting credit). Freedoom used BSD for assets, which is very permissive (almost like CC0, meaning commercial use allowed as long as some attribution is given if at all). This might be to align with Doom source’s license. For us, since our whole aim is open source UO-like, a copyleft like CC-BY-SA (share alike) could ensure that any derivative of our free assets remains free. But that might discourage use in other projects. It’s a choice to be made by the community.
In comparing ecosystems, it’s clear that communities thrive when:
- The formats are open and well-documented.
- Tools are abundant (often community-contributed).
- There’s a mechanism for mod distribution and combination (like how multiple WADs or PAKs can stack).
We can incorporate these lessons by making .kool
as open as possible and supporting extensibility:
For instance, if someone wants to add a new section to .koolmap
(say, weather data or NPC spawn points embedded in map), our versioning and structured chunks should allow adding extra data either in reserved fields or as additional chunks at end. Possibly we could design it like a chunked RIFF-like format (where each chunk has an ID and size). But that adds overhead for a mostly fixed-structure need. Instead, we left some reserved header fields and versioning for future growth.
10. Open Licensing and Documentation Standards
To truly be an open-source alternative, everything about the format and content should be under permissive licenses. Here are recommendations:
-
File Format Specification License: Use a public domain or permissive license (e.g., CC0 1.0 Universal for the spec text, or unlicense). This ensures no restrictions on implementing the format. Anyone can write their own reader/writer without legal concern. We will publish the format docs (like this report, perhaps refined into a formal spec document with field tables and examples) on a public repository or website (e.g., a GitHub wiki or ReadTheDocs). The documentation should be kept up-to-date with version changes. Ideally, maintain a single authoritative spec so there’s no divergence.
-
Code Licenses: For reference implementations (the Python/C++ examples, converter tools, ClassicUO modifications), a permissive license like MIT or BSD 2-clause is advisable. MIT is very common in open source game tools and would allow inclusion in both open and closed-source projects (if someone wanted to support
.kool
in a proprietary engine, they could). Since ServUO and ClassicUO are themselves under MIT (ClassicUO) and maybe GPL (ServUO? Actually ServUO might be GPL), we should check: if ServUO is GPL, any linking of code would have to be GPL; but if we just publish spec and maybe MIT code, they can incorporate by rewriting if needed. In any case, MIT simplifies things. -
Asset Licenses: As mentioned, likely Creative Commons for art/music. CC-BY 4.0 (attribution required) or CC-BY-SA 4.0 (share-alike) could be used. CC-BY allows commercial use as long as credit is given – which some might be okay with to spread usage. CC-BY-SA ensures derivatives remain open, which philosophically aligns with open-source but might restrict use in closed games. Freedoom chose BSD for assets to maximize engine compatibility (some Doom source ports might have not wanted strong copyleft on data? Not sure). We could consider CC0 (public domain dedication) for certain things to encourage maximum reuse, especially for anything that’s more data than art (like tiledata info, which is just numbers).
-
Trademark and Naming: We must avoid using “Ultima” or related trademarks in our project name or file names (so calling it
.uomap
or something is out). The name.kool
is arbitrary (though cheeky as “Kool” sounds like “cool”). We should ensure the naming and branding of the project doesn’t infringe EA/Broadsword trademarks. Perhaps name the project something entirely original (e.g., “Open Britannia” might still infringe Britannia which is used in UO, so maybe not; something like “WorldForge” except that’s taken by another project; one might pick a codename unrelated to UO lore). -
Documentation Standards: We should maintain clear, versioned documentation. For example:
- Keep a changelog of format versions (so developers know what changed in
.koolmap v2
). - Provide examples (a small sample map file with annotation of its bytes).
- Possibly provide a machine-readable spec (like JSON schema or Kaitai Struct definition) to formally describe the binary format. Kaitai Struct, for instance, is a DSL to describe binary layouts which can generate parsers in multiple languages. Writing a
.ksy
spec for.koolmap
would be a neat way to ensure accuracy and allow others to easily parse it with generated code. We could include that in docs. - Use consistent terminology (cells, blocks, etc., as we have).
- Encourage contributions to docs when format is extended.
- Keep a changelog of format versions (so developers know what changed in
-
Community and Collaboration: Host the project on a platform like GitHub or GitLab. Possibly have separate repos: one for the spec/docs, one for tools, one for assets. Use issue trackers to gather feedback, e.g., if someone finds a bug or needed feature in format, discuss openly and version accordingly.
-
Open for extension: With a version field, if someone outside the core devs needs to add something, they could bump a minor version and add a chunk, documenting it. If it’s useful for all, merge it into main spec. If not, at least the versioning means old readers won’t break (they might ignore unknown new data if we design it that way).
-
No platform lock-in: Ensure the format remains free of platform-specific constraints. (For example, use standard data types, avoid anything that depends on little-endian vs big-endian beyond the spec saying “little-endian encoding”). This way, even decades later on new architectures, it can be read as long as documentation is available.
By adopting these open practices, we follow the spirit of projects like GNU, Creative Commons, and the open mod communities. The ultimate measure of success will be if other developers can use our format and assets to create their own UO-like worlds or even totally different games (with appropriate credit). This open format could even transcend UO and serve as a general 2D isometric RPG world format, much like how WAD was used by games beyond Doom engine.
Conclusion: The above sections have detailed the design and rationale for a modern .kool
format to replace UO’s .mul
files. We covered the inner workings of UO’s original format, the legal necessity of original content, and created a blueprint for an open, efficient replacement with compatibility in mind. The .koolmap
spec provides chunked, compressible map data akin to a region file system, enabling both smooth streaming and easy editing. We discussed how to implement reading/writing in code, and integrate with Godot for rendering and editing – including handling isometric depth sorting (using Y-sort and z-index techniques) and leveraging Godot’s cross-platform capability for PC and mobile. We also set forth the tooling ecosystem required (converters, editors, validators) and paralleled our approach with other modding standards (Doom WAD, Quake PAK) to ensure we learn from their success. Finally, we emphasized that all of this must be under open licenses and well-documented to truly foster a community-driven project. With these measures, the “.kool” format can become a robust, legally sound foundation for Ultima Online aficionados to build new worlds and preserve the classic gameplay experience in an open, modern engine for years to come.