No description
  • Java 71.9%
  • TypeScript 25.1%
  • CSS 2.5%
  • Dockerfile 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mine13zoom 263b1b5b4a
Some checks failed
build / Build XaeroSync mod (push) Failing after 4m12s
idfk
2026-08-18 22:02:24 +02:00
.github/workflows Bake-in player config + XOR-obfuscate secrets; make relay deletes server-only 2026-08-14 21:43:14 +02:00
dist Add relay Dockerfile; never track the baked player jar (public repo) 2026-08-14 22:00:37 +02:00
mod idfk 2026-08-18 22:02:24 +02:00
research XaeroSync: optional add-on to share Xaero World Map explored terrain via remote relay 2026-08-14 20:03:26 +02:00
sync-server idfk 2026-08-18 22:02:24 +02:00
.gitignore v1.0.1: adaptive proof-of-work on the upload path + bump version 2026-08-15 18:55:46 +02:00
README.md idfk 2026-08-18 22:02:24 +02:00

XaeroSync — share Xaero's explored terrain across servers & clients

An optional add-on for Xaero's World Map (and works alongside Xaero's Minimap) for NeoForge 1.21.1 that shares your explored-terrain map across game servers and clients through a small, remote relay server that you configure.

It is an add-on, not a replacement: it never reimplements or replaces anything Xaero does. It only (a) watches the Xaero World Map save folder and (b) talks to your relay, leaving all map rendering/exploring to Xaero itself.

What does it depend on?

Xaero mod Needed? Role
Xaero's World Map Yes (functional) The source of the terrain data this add-on syncs. Without it there are no map files, so XaeroSync stays idle and logs a warning.
Xaero's Minimap No (optional, this add-on lists it as optional) Only needed if you also want the optional shared player radar. The terrain feature never touches the Minimap jar.
Xaero's Lib No (optional) The Xaerocs' shared library is pulled in by World Map itself, not needed by this add-on directly.

In short: this add-on's functional dependency is Xaero's World Map. It has zero compile-time coupling to any Xaero class — it finds World Map's data folder via reflection and safely does nothing when World Map (or the folder) is absent.

It is both a server-side and a client-side mod: they talk to the same relay, so a player on one server or singleplayer world sees the terrain that players on other servers/worlds have collectively explored — all through central map data you own.


Table of contents


How it works

Xaero's World Map persists explored terrain as per-dimension region files on disk. XaeroSync watches that folder, uploads new/changed regions to your relay, and downloads regions contributed by other peers.

 Xaero World Map                    XaeroSync mod                Your relay server
 ┌───────────────┐      upload      ┌───────────────┐   JSON+zip  ┌──────────────────┐
 │ gameDir/      │  ┌─────────────▶ │ XaeroSync     │ ───────────▶ │ sync-server      │
 │ xaero/        │  │               │ (client side) │  ◀────────── │ (bun WebSocket) │
 │ world-map/    │  │               └───────────────┘    download  └───────┬──────────┘
 │  world/       │  │                                                        │ stores region
 │   null/       │  └─────────────  XaeroSync (server side)                 │ blobs keyed by
 │   DIM-1/      │  ┌─────────────▶ ┌───────────────┐  ◀───────────────── ───┘ world/dim/name
 │    mw$0/      │     on the game  │ watches, diff,│   other peers share the
 │     *_.zip    │     server too   │ push + pull   │   same map through it
 └───────────────┘                  └───────────────┘

A sync round (every N seconds):

  1. Scan the Xaero World Map folder for shareable .zip region files.
  2. Upload any local region that is new or changed to the relay.
  3. Manifest — ask the relay what regions it has for this world (the relay signs the manifest so the client can verify it).
  4. Download remote regions and merge them into the local Xaero folder at 16×16-chunk granularity, so your rendered map benefits without ever losing explored terrain.

Why downloads land in the right place. Xaero names each world's data folder after the machine-specific world id — Multiplayer_<serverIp> on a client connected to a server, the (converted) singleplayer world folder name, or the world save folder name on a dedicated server. Those ids differ between peers, so the relay manifest keys embed the uploader's id as the first path segment. When downloading, XaeroSync therefore re-roots every region onto the local active world folder (the id the local Xaero is actually using right now, read live from Xaero's own session or mirrored from its naming rules). Without that re-root, a downloaded region would be written under the uploader's folder name and Xaero would never render it — the shared map would only ever show on the web viewer, never in-game.

BOTH sides merge — the client too

The relay isn't the only place terrain gets merged. The client also runs the same region codec and merges every download into its local canonical region before writing it to disk. So even if the relay only has part of a region, or a hostile relay sends a near-empty file, the client's own explored tiles always survive.

The relay MERGES, it doesn't pick a winner

The relay does not just keep "the file with the most chunks". It parses each uploaded region (its tiles/pixels), merges it at 16×16-chunk granularity into the canonical region for that world, re-serializes, and persists the authoritative merged region to disk. So when player A explores the north-east of a region and player B explores the south-west, both territories survive:

Region A (alice):  slots {0,0},{1,1},{2,2}    ┐
Region B (bob):    slots {5,5},{6,6},{7,7}    ┴─▶ merged {0,0,1,1,2,2,5,5,6,6,7,7}

Both upload to the SAME <regionX>_<regionZ>.zip name. The relay holds:
  data/<world>/<dim>/<regionX>_<regionZ>.zip          ← authoritative merged .zip
  data/<world>/<dim>/<regionX>_<regionZ>.zip.meta.json ← tile/chunk count + contributors

Any downloader gets the merged file back, so every client/server sees the collective explored terrain, not just whoever uploaded the biggest single file. Overlapping, already-known tiles are idempotent (re-uploading adds nothing new).


Reverse-engineering summary

I decompiled xaerominimap-neoforge-1.21.1-26.4.2.jar (Vineflower) and its companion xaeroworldmap-neoforge-1.21.1-1.44.2.jar and studied the save system. Referenced source is kept under research/.

The Xaero World Map on-disk format

Map data root (client and dedicated server alike):

<gameDir>/xaero/world-map/

Inside that root:

<world>/<dimension>/<multiworldId>/<regionX>_<regionZ>.zip
  • <world> — the world/save id.
  • <dimension>null (overworld), DIM-1 (nether), DIM1 (end), or <namespace>$<path> for custom dimensions.
  • <multiworldId> — a multiworld id such as mw$0 (or custom).
  • <regionX>_<regionZ>.zip — a region: a 512×512 block tile of the explored map. Cave layers live under an extra caves/<layer>/ subfolder.
  • cache_<version>/*.xwmccaches, written only in singleplayer; not the shareable region format.

Key references (from xaero.map.file.MapSaveLoad):

  • getRootFolder(world) = WorldMap.saveFolder / world
  • getMainFolder(world,dim) = WorldMap.saveFolder / world / dim
  • getNormalFile(region) = <mainFolder>/<mwId>/<regionX>_<regionZ>.zip
  • WorldMap.saveFolder = <gameDir>/xaero/world-map

The region binary format (what XaeroSync parses for merging)

Each region .zip holds a single entry, region.xaero:

[int8] 0xFF                    “has full version” marker
[int32 big-endian] fullVersion (major<<16 | minor)  — current major 6 / minor 8
— repeated for each populated 8×8 chunk-slot:
  [int8]  slot = (slotO<<4 | slotP)                 — a tile-chunk in the region
  — 4×4 map tiles (each = one 16×16 MC chunk):
    [int32]  first pixel parametres  (or -1 = empty tile)
    16×16 pixels (state/height/light/biome/overlays, via a GLOBAL palette
    that is shared across the whole file), then tile-level cave markers.

Per-pixel parametres bits include a global block-state palette (index or inline Minecraft NBT block-state via NbtUtils.writeBlockState), a biome palette, height (12-bit signed) / top-height, light, and overlay water/block states. Because the palettes are global to the whole region file, merging two files requires fully re-building the palettes and re-encoding every pixel — exactly what sync-server/region.ts implements.

XaeroSync parses this format ({@link sync-server/region.ts}) so the relay can merge regions at chunk granularity and re-serialize the canonical file. It reads and re-emits block-state NBT bytes verbatim, so it is robust to MC version changes and never loses data.

Why terrain (not player positions) and why World Map

Your original ask was to share "the map of players" to a remote server. Two findings from the decompilation:

  1. Player radar positions are a fully-supported, self-contained API inside the Minimap jar (RenderedPlayerTrackerManager.register(...) + IRenderedPlayerTracker). A shared "player radar" is very doable and I wrote the design below, but — per our chat — you chose explored terrain instead.
  2. Explored terrain is only kept in RAM by the Minimap jar itself. The on-disk region files that persist explored terrain belong to the separate Xaero's World Map mod (xaero.map.file.MapSaveLoad). So syncing terrain requires World Map (as a companion) — which is exactly what this add-on does.

Optional: sharing player radar positions

For completeness, the same architecture can trivially add a shared player radar. Xaero's Minimap collects what it renders from a RenderedPlayerTrackerManager (a registry of IRenderedPlayerTracker). A mod that calls:

HudMod.INSTANCE.getPlayerTracker().register("xaerosync", myRenderedPlayerTracker);

injects tracked players into the minimap's player radar. myRenderedPlayerTracker returns an Iterator of remote players and an ITrackedPlayerReader providing getId / getX / getY / getZ / getDimension — exactly the fields the relay already carries. The relay even has an optional presence message for this. It's not enabled in this build, but the wiring is there.


Repository layout

xaero/
├── dist/                     # built artifacts (copy these into your mods/ folder)
│   ├── xaerosync-1.0.4.jar            ← your add-on  (built)
│   ├── xaerominimap-neoforge-…jar     ← Xaero Minimap (optional companion)
│   └── xaeroworldmap.jar              ← Xaero World Map (functional dependency)
├── mod/                      # the NeoForge mod (client + server sides)
│   ├── src/main/java/com/xaerosync/
│   │   ├── XaeroSyncMod.java          # @Mod entry
│   │   ├── ClientSyncHandler.java     # client-side tick driver
│   │   ├── ServerSyncHandler.java     # dedicated-server tick driver
│   │   ├── SyncWorker.java            # scan → upload → manifest → download (secure)
│   │   ├── RegionIndex.java           # walks the Xaero folder, filters regions
│   │   ├── WorldMapFolder.java        # locates the map folder (reflection)
│   │   ├── WorldKeys.java             # world-key derivation
│   │   ├── RelayClient.java           # JDK WebSocket protocol client
│   │   ├── SyncScheduler.java         # daemon executor
│   │   ├── SyncConfig.java            # NeoForge config spec
│   │   ├── RegionCodec.java           # Xaero region codec port (parse/serialize/merge)
│   │   ├── NbtCodec.java              # Minecraft NBT reader (raw block-state blobs)
│   │   ├── ZipCodec.java              # JDK-backed single-entry region zip I/O
│   │   ├── LocalRegionMerger.java     # non-destructive client-side merge + sidecar
│   │   ├── SyncSecurity.java          # path confinement, checksums, Ed25519 verification
│   │   ├── SyncStats.java             # live sync/health counters for the status command
│   │   └── SyncCommands.java          # /xaerosync status command
│   ├── src/test/java/com/xaerosync/   # JUnit tests for the codec/merge/security port
│   ├── build.gradle / settings.gradle / gradle.properties
│   └── gradlew (+ wrapper)
├── sync-server/              # the remote relay (bun)
│   ├── server.ts             # WebSocket relay + tiled live-map HTTP API
│   ├── map.ts                # Xaero regions -> cached multizoom tile renderer
│   ├── png.ts                # minimal PNG encoder for rendered map tiles
│   ├── frontend/             # React + TypeScript + Leaflet viewer (Vite)
│   │   └── src/              # map UI, custom Minecraft CRS, API client
│   ├── store.ts              # authoritative on-disk store (canonical merged region)
│   ├── region.ts             # Xaero region binary codec (parse/serialize/merge)
│   ├── zip.ts                # minimal ZIP in/out for the region container
│   ├── nbt.ts                # Minecraft NBT length-walker for block states
│   ├── test-region.ts        # codec + merge unit tests  (bun run test)
│   └── .env.example
└── research/                 # decompiled reference sources (for study)

Build the mod

Requires JDK 21 and network (downloads NeoForge + Minecraft toolchain on first run).

cd mod
./gradlew build
# output: mod/build/libs/xaerosync-1.0.4.jar

A prebuilt copy is already in dist/.


Run the relay server

Requires bun.

cd sync-server
bun install
bun run build:frontend
cp .env.example .env     # then edit TOKEN and PORT
bun run server.ts

For frontend development, run bun run dev alongside the relay; Vite proxies map API requests to localhost:8787. The Railway Dockerfile installs and builds the React frontend automatically.

Quick checks:

curl http://localhost:8787/health    # {"ok":true,...}
curl http://localhost:8787/stats     # region count, merged tile total, peer list
curl http://localhost:8787/worlds    # worlds + dimensions with region counts (JSON)
bun run test             # codec + authoritative-merge unit tests

Live explore map (browser-facing)

Open the relay's root domain in a browser and you'll get the React-powered Live Explore Map: an interactive, multizoom tile map of the terrain that the whole community has collectively explored. The Leaflet viewer requests only the visible 256×256 tiles, so you can freely drag across the world and zoom from a large-area overview down to full one-block-per-pixel detail without stretching a single fixed PNG. Live mode checks for changed regions and swaps in fresh tiles automatically.

  • Root / with a browser user-agent → the built React application.
  • Root / with a script (e.g. curl) → the JSON worlds catalogue instead.
  • /tile/<zoom>/<tileX>/<tileZ>?world=<key>&dim=<dim> → a lazily rendered PNG map tile.
  • /bounds?world=<key>&dim=<dim> → explored bounds, tile geometry, and data epoch.
  • /worlds{ worlds: [{ world, regions, dims: [{ dim, regions }] }] }.
  • /map?world=<key>&dim=<dim> → a legacy stitched overview PNG.

The server parses each region's block-state NBT to colour blocks (grass, sand, water, nether, etc.), falls back to biome colouring for plain-surface pixels, and adds gentle height-based shading. Rendered 512×512 region colour grids and visible web tiles are bounded/cached in memory for fast navigation. The viewer supports mouse/touch panning, scroll/pinch zoom, fit-to-exploration, world and layer switching (including Xaero cave layers), and live block coordinates.

Relay configuration (.env)

Key Default Meaning
PORT 8787 Listen port.
TOKEN change-me… Shared secret; every mod must match it (used for the connection handshake).
DATA_DIR ./data Where the authoritative merged regions (.zip + .meta.json) are stored.
HEARTBEAT_TIMEOUT 60 Seconds of silence before a peer is dropped.
RATE_BYTES_PER_SEC 2097152 Per-peer upload bandwidth budget (2 MB/s); a peer exceeding it is rejected that round.
RATE_BURST_BYTES 33554432 Allowable burst before the token-bucket starts throttling.
POW_DIFFICULTY_BASE 16 Initial proof-of-work difficulty (leading zero bits) a client must meet to upload.
POW_DIFFICULTY_MAX 26 Ceiling difficulty; rises adaptively with a client's sustained upload rate.
POW_FREE_REGIONS_PER_MIN 2000 Regions a peer may upload per minute at base difficulty before the byte-driven ramp applies (generous so a new player's full map uploads fast).

On first start the relay generates an Ed25519 signing keypair under <DATA_DIR>/signing/ and prints its public key. Clients trust that public key (put it in relay.publicKey). It's also published at http://<relay>/pubkey.

For remote access, put the relay behind a reverse proxy that terminates TLS and use wss:// on the client.


Installation & configuration

On every machine that should share the map (clients and any dedicated servers), put these in the mods/ folder:

  • xaerosync-1.0.4.jar
  • Xaero's World Map for NeoForge (the functional terrain source — required for the add-on to do anything)
  • Xaero's Lib (the Xaeros' shared library)

Config (editable in-game or in config/xaerosync-client.toml / xaerosync-server.toml):

Option Default Meaning
relay.relayUrl ws://127.0.0.1:8787 URL of your relay.
relay.token change-me-… Must match TOKEN on the relay.
relay.publicKey (empty) Ed25519 public key of the relay (hex, from /pubkey). Used to verify region/manifest attestation. Empty = trust the key the relay announces (TOFU).
world.worldKey (auto) Set the same non-empty value on every server/clients that should share one map. Leave blank to derive from world/server.
world.enabled true Master switch.
sync.upload / sync.download true Individually toggle directions.
sync.intervalSeconds 20 Seconds between rounds.
sync.networkTimeoutSeconds 15 Network timeout.
sync.maxRegionBytes 67108864 Max bytes of a single region the client will accept from the relay; larger blobs are rejected.

worldKey is the important one. To merges the explored maps of several servers into one shared map, give them all the same worldKey. Different worldKeys keep maps separate on the same relay.

The xaerosync status command

In-game (singleplayer or on a server running the mod), type:

  • /xaerosync — full status: relay health & last ping, current world, whether a round is in progress, upload/download counts and bytes this round, chunk tiles gained, per-round throughput, and an ETA to finish pending uploads when syncing.
  • /xaerosync ping — forces a round-trip to the relay and shows the RTT.
  • /xaerosync ss — one-line abbreviated summary.

Example:

--- XaeroSync status ---
Relay       : ws://127.0.0.1:8787
World       : myworld
Connection  : connected (last ping 12 ms)
State       : currently syncing
Round       : in progress for 3.2 s  |  upload 4/40 regions  |  download 2 regions
Throughput  : up 1.2 MB, down 64 KB (~380 KB/s)
Chunks      : +512 up, +64 down  |  merged 3 files, +128 tiles gained
ETA         : ~18 s

Security model (what happens if the relay is compromised)

The relay is a third party; a compromised or hostile relay must never be able to make a client write arbitrary bytes to arbitrary paths, nor erase the player's explored terrain. The client applies belt-and-braces layers, all fail-closed (reject rather than risk):

  1. Path confinement. Every relay-supplied manifest key / region name / dim segment is re-whitelisted to ASCII [A-Za-z0-9._$%\-~] and re-validated as a well-formed N_N.zip region. No .., no absolute paths, no slashes inside a segment, and the final resolved path must stay inside the Xaero save folder. A malicious region field can never become ../../.bashrc.

  2. Asymmetric Ed25519 attestation. The relay signs each region's identity+checksum and the whole manifest with its Ed25519 private key. The client verifies with only the operator's public key (configured in relay.publicKey, or adopted from the relay on first connect — TOFU). Because the private key never leaves the relay and is never shipped to clients, a reverse-engineered client or a snatched TOKEN can't forge attestations. Tampered manifests, swapped region identities or altered bytes are detected and rejected. Honest limit: if the relay box itself is fully rooted the attacker holds the private key, so this protects against abused clients / MITM / relays without the key, not total server compromise (that's what the client-side merge defends against).

  3. End-to-end checksum. The client recomputes the SHA-256 of the actual downloaded bytes and compares it to the attested checksum — catching a relay that signs then swaps the payload mid-transfer.

  4. Format/version validation. Every download is inflated as a single-entry region.xaero zip with a hard size cap, parsed with the version-gated codec, and rejected if it is malformed or an unsupported region version. Garbage never reaches disk.

  5. Non-destructive merge. Anything that passes validation is merged into the local canonical region (local tiles win on overlap), never a blind overwrite. Even a valid-but-empty or partial region cannot delete explored terrain.

  6. Size caps. Per-region download limits (sync.maxRegionBytes) bound memory and disk so a rogue relay can't exhaust client resources.

  7. Never-delete timestamp merge. The relay records the newest data-generation time clients report (generatedAt) but the merge is strictly additive — tiles from different explorers always combine, and tiles only ever move forward. Nothing is ever deleted; a region can only grow. Overlapping, older data never shrinks or overwrites newer explored terrain.

  8. Versioned full-region sync. The client fetches the complete authoritative region when its signed manifest checksum changes (or when Xaero rewrites the local target), then performs a tile-level local merge. An earlier slot-bitmap optimization was removed because a slot can be only partially explored; it could incorrectly classify remote tiles in a shared slot as already present. Unchanged manifest entries are skipped without touching the local file.

  9. Relay upload rate limiting. Each peer has a per-second token-bucket budget (RATE_BYTES_PER_SEC); a peer exceeding it is rejected that round, so a rogue client can't flood the region store with gigabytes of fake regions.

  10. Adaptive proof-of-work. Before the relay accepts an upload, the client must find a nonce whose SHA-256 meets the relay's challenge difficulty. The difficulty starts low (POW_DIFFICULTY_BASE) and rises per-client with their sustained upload rate, so honest explorers never notice but an attacker blasting fake regions pays real CPU per byte.

When you configure the relay's Ed25519 public key (relay.publicKey), the checks are strict: regions/manifests without a valid signature are refused outright. With an empty key the client trusts the key the relay announces (TOFU) — usable but weaker.


Wire protocol

WebSocket, UTF-8 JSON text + binary frames.

Messages (client → relay): hello, region_put (then binary), region_get (then binary reply), manifest, region_list, region_meta, ping, presence, get_presence.

Messages (relay → client): hello_ok, region_put_ready, region_ack, region_found (then binary), manifest, region_list, pong, presence_update, error.

Every request carries an id which the relay echoes on the corresponding response (used by the client to pair asynchronous replies). Binary frames always follow the text envelope they belong to.

Full details are in sync-server/server.ts.


FAQ / limitations

  • Why does the client need its own world folder? Xaero names world folders per machine (Multiplayer_<ip> on a client, the world save name on a dedicated server), so downloaded regions are re-rooted into the local active world folder before writing — the shared terrain renders in-game on every peer, not just in the web viewer.

  • Will this share player positions? Not in this build. You chose explored terrain. See Optional: sharing player radar positions.

  • Why do I need World Map? The Minimap jar itself only keeps terrain in RAM; World Map owns the on-disk region files this add-on shares.

  • Singleplayer caveat — World Map writes shareable .zip regions reliably on multiplayer (and the dedicated server writes them by design). In pure singleplayer it favours the cache (*.xwmc) format. For the best results, run at least one dedicated server with the mod so the collective map is authoritative — every connected client still merges everything.

  • Version / format — XaeroSync reads a bounded slice of the region format and re-emits block-state NBT bytes verbatim. Newer/unknown region versions are rejected safely; the codec is version-gated and never crashes the relay.

    About the region version staying stable on 1.21.1: the region save version is a hardcoded constant in Xaero World Map's code, NOT tied to the Minecraft version. The 1.21.1 build you run (World Map 1.44.2) writes major 6 / minor 8 (393224), identical to other MC versions using the same World Map release. So while you play 1.21.1, the region version does not change on its own.

    ⚠️ One important caveat: Xaero's newer World Map releases (e.g. the one shipping for MC 1.21.9) bumped the region format to major 7 / minor 8 (458760). XaeroSync's codec targets major 6. If Xaero ever releases a World Map update for 1.21.1 that bumps the region major to 7, existing 6.x regions would need a codec bump to keep merging. Today that 7.x build only exists on the separate newer-MC line, so 6.8 is stable for your setup — but if you ever update World Map on 1.21.1 to a 7.x build, re-run the relay with an updated codec (forward-compat note in region.ts).

  • Conflict resolution / merging — the relay parses each region and MERGES it at 16×16-chunk granularity into the canonical on-disk region, then re-serializes. Two players exploring different parts of the same region both contribute; overlapping/known tiles are idempotent. There is no "biggest file wins" decision, and the client merges downloads the same way so explored terrain is never lost.

  • Is it safe/output-safe? All network work runs on a background daemon thread; nothing runs on the render thread. Both the codec and store write atomically (temp file + rename). Downloads are additionally version-validated, size-capped, path-confined, checksum-verified, HMAC-attested, and merged (not overwritten) — see Security model.


License

This add-on's code is MIT. Xaero's Minimap, Xaero's World Map and Xaero's Lib are © xaero96 (distribute per their terms). The relay (sync-server) is MIT.