Files
MCMapper-Backend/README.md
T
octoturge de1fac852c docs: models.test.ts now verified against live Postgres
Fixed Windows-to-WSL2 Docker connectivity (real dockerd runs inside
the WSL Ubuntu distro, not Docker Desktop) so the Phase 13 test that
had been written but unrun could actually be exercised. api (98/98)
and frontend (39/39) suites both pass against real Postgres/Redis/
MinIO now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
2026-08-10 05:14:03 +02:00

348 lines
24 KiB
Markdown

# MCMapper-Backend
Map-render backend for [MCMapper-Mod](https://git.octoturge.com/octoturge/MCMapper-Mod). Does
all the heavy lifting a thin in-game mod shouldn't: persists world state, renders 2D tiles and
3D meshes, relays chat, and serves the web map viewer — instead of the MC server itself burning
CPU/RAM on rendering the way Bluemap/Dynmap do.
## Services
Three independently-deployable services, each its own docker-compose service:
- `api/` — ElysiaJS on Bun. The I/O layer: WS gateway for mod connections, chat relay, chunk
store, marker/waypoint sharing, admin config, region export, tile/mesh serving.
- `worker/` — Rust. Tile rasterization and chunk meshing, consumed off a Redis dirty-chunk
stream. Stateless — scale it with `docker compose up --scale worker=N`, or run instances on
separate hardware pointed at the same Postgres/Redis/MinIO over a private network. Rendering
strategy is config-selectable (`RENDER_BACKEND=cpu`/`gpu`/`hybrid`, see `worker/.env.example`)
behind a `RenderBackend` trait (Phase 8): `cpu` (rayon, default) parallelizes across a whole
batch of dirty chunks; `gpu` additionally offloads tile shading and per-voxel
face-visibility extraction to a `wgpu` compute shader per chunk/section (greedy-mesh
merge/compaction stays CPU-only regardless — sequential/branchy, not GPU-parallel-friendly);
`hybrid` offloads only tile shading, keeping meshing on CPU. `gpu`/`hybrid` fall back to `cpu`
automatically (logged) if no compatible GPU adapter is found — see `worker/src/render/gpu.rs`'s
doc comment for the current known limitation (each tile/section is its own GPU dispatch, so at
small batch sizes `cpu` currently outruns `gpu`/`hybrid` — see `cargo run --release --example
benchmark`, in `worker/`, for real numbers on your own hardware).
- `frontend/` — ElysiaJS + Pug + Tailwind 4 + Alpine.js. The public-facing pages (map viewer,
chat, admin panel). Stateless — no DB access, calls `api` for anything server-rendered; the
browser's own live map/chat/tile traffic talks to `api` directly, not proxied through here.
Plus `postgres` (source chunk data, accounts, chat history, config, render-artifact metadata
pointers), `redis` (dirty-chunk queue, pub/sub, link-code TTLs), and `caddy` (reverse proxy:
`/ws` + `/api/*``api`, everything else → `frontend`).
Postgres/Redis are never exposed publicly — only reachable on the compose network or a private
network (VPN/Tailscale/LAN) for remote `worker` instances.
### Object storage
Rendered tile PNGs and mesh binaries live in a `mcmapper-tiles` bucket on an existing shared
MinIO instance (`devstack-minio` on octo-winsrv) rather than a per-stack `minio` container —
kept out of Postgres so backups stay free of large binaries, and any `worker` instance (local or
remote) has a shared place to write output. `api`/`worker` authenticate with a dedicated
`mcmapper` access key whose policy (`mcmapper-tiles-rw`) only grants
`GetObject`/`PutObject`/`DeleteObject`/`ListBucket` on that one bucket — it can't see or touch
anything else on the shared instance. Provisioned via:
```
mc mb myminio/mcmapper-tiles
mc admin policy create myminio mcmapper-tiles-rw mcmapper-policy.json # see git history for the policy JSON
mc admin user add myminio mcmapper <generated secret>
mc admin policy attach myminio mcmapper-tiles-rw --user mcmapper
```
`api/.env.example`/`worker/.env.example` point `MINIO_ENDPOINT`/`MINIO_PORT` at that instance's
LAN address; swap to the public gateway (`s3.octoturge.com:443`, `MINIO_USE_SSL=true`) if a
stack isn't on the same LAN. `MINIO_SECRET_KEY` is a real credential and is deliberately **not**
committed — set it in an untracked `./api/.env` / `./worker/.env` (docker-compose layers those on
top of the tracked `.env.example`, see `docker-compose.yml`).
### Real block textures (Phase 11)
`worker/src/palette.rs`'s hand-picked color table is now a *fallback*, not the only source of
2D-tile colors. Set `ACCEPT_MINECRAFT_EULA=true` (`worker/.env.example`, off by default — mirrors
BlueMap's `accept-download`) and the worker downloads Minecraft's official client jar directly
from Mojang's own public `launchermeta`/`piston-meta`/`piston-data` endpoints (same source the
real launcher uses — no redistribution, so no licensing issue) on first startup, averages every
`assets/minecraft/textures/block(s)/*.png` into a representative color, and caches the result to
disk (`TEXTURE_CACHE_DIR`, default `./cache`) so it isn't re-downloaded every restart.
`MC_TEXTURE_VERSION` (default `1.12.2`) picks which version's jar to pull from — this project's
priority targets (1.7.10/1.12.2) share the pre-1.13 `textures/blocks/` (plural) naming, which
`worker/src/textures.rs` checks alongside the modern `textures/block/` path.
A handful of blocks (grass block top, leaves, water, lava) are deliberately *excluded* from the
texture-averaged path (`worker/src/block_names.rs`'s doc comment) and keep their hand-picked
color: their real textures are either biome-tinted at runtime (grayscale in the raw file) or
animated/transparent frame strips, so averaging the raw asset would produce a wrong color, not
just an approximate one.
**`texturepacks/<name>/`** (flat `*.png` files, mirrors Dynmap) lets an operator override the
downloaded defaults — set `TEXTURE_PACK=<name>` and its colors are layered on top of the vanilla
palette at startup. Each server also has an admin-configurable `texturePack` field (`/admin`,
`servers.texture_pack` — same shape as `waypointFormat`) recording *which* pack an operator wants
per server — **but render-time application is currently worker-wide only, via `TEXTURE_PACK`, not
yet resolved per-server from that column.** True per-server resolution needs the render pipeline
to thread a server-scoped palette through `main.rs`'s batch loop instead of one process-wide
`OnceLock` (`worker/src/render/mod.rs`) — a real architectural change, deferred rather than rushed.
**Modded blocks**: Forge doesn't split client/server jars, so a modded server's own classpath
already has every loaded mod's texture assets, just unused server-side. The `forge-1_12_2` mod
leaf (Enigmatica 2, this project's primary target) extracts them once at startup
(`BlockAssetExtractor`, best-effort: guesses each block's texture by its registry-name path
segment, not a real blockstate/model JSON resolution — that's Phase 13's job, see below) and ships two new
WS messages after connecting: `block_registry` (numeric id -> registry name, needed since a
numeric `blockId` alone is meaningless without the mod list that assigned it) and `block_textures`
(the extracted PNGs, batched). The api stores both (`api/src/textures.ts`: registry rows in a new
`block_registry` table, texture PNGs in the shared MinIO bucket with pointer rows in
`block_textures`) — **but, like the `texturepacks/` case above, nothing reads these tables into
the render pipeline yet.** This is a deliberate two-step boundary: "ingest and store" (done, real,
tested) vs. "resolve into a per-server palette at render time" (the same deferred piece as
`texturepacks/` above, and naturally solved together). `forge-1_7_10`/`neoforge-26_1` don't
implement extraction yet either — `BackendConnection#sendBlockRegistry`/`#sendBlockTextures` are
on the shared interface (so any leaf can adopt them later with no protocol change), but only the
1.12.2 leaf calls them so far, matching this phase's Enigmatica-2-focused verification target.
### Texture atlas + UV-mapped 3D meshes (Phase 12)
Phase 11 only fed texture-averaged colors into the 2D tile path — `worker/src/mesh.rs`'s 3D
section mesher still called the plain hand-picked `palette::color_for`, so the 3D viewer's "now
accurate" claim in that phase's writeup wasn't actually true yet. Fixed first: `mesh.rs` now calls
`color_for_textured` too, same as the 2D path.
On top of that, `worker/src/atlas.rs` packs every block texture the worker already downloads (see
Phase 11 above) into a single RGBA PNG atlas (one native `16x16` tile per texture, deterministically
laid out in a square-ish grid) plus a `texture name -> normalized [u0,v0,u1,v1]` rect map, cached
to disk like the palette. It does its own jar download rather than sharing Phase 11's — a small,
one-time, cached duplicate fetch, accepted to keep the two build paths independent. Unlike the
palette's post-hoc `texturepacks/` overlay, the atlas always rebuilds (and caches under its own
`-<pack>` suffixed filename) when `TEXTURE_PACK` is set, since there's no cheap way to patch one
tile back out of an already-packed image.
`mesh.rs` now emits two new per-vertex buffers alongside the existing position/normal/color ones:
tile-relative `uv` (unbounded — a merged quad spanning N blocks has that UV coordinate run 0..N,
not 0..1) and `atlasRect` (the same 4 floats repeated for all 4 vertices of a quad; `[0,0,0,0]`
sentinel when the block has no atlas entry — the frontend falls back to the flat vertex color for
that quad). This is a hard break in the section-mesh binary wire format (v2) — safe to do without
any migration, since rendered meshes are a fully regenerable cache (MinIO + a Postgres pointer
row), not a durable artifact; an old-format blob just gets silently overwritten the next time that
section's dirty-chunk job runs.
The atlas PNG + UV-map JSON are uploaded once at worker startup to fixed, version-agnostic MinIO
keys (`atlas/current.png`, `atlas/current.json` — matches the worker-wide-only scope already
established for the palette/texturepack in Phase 11) and served by `api` at `GET /api/atlas.png`
/ `GET /api/atlas.json` (404 until a worker with `ACCEPT_MINECRAFT_EULA=true` has built one).
The live Babylon 3D viewer (`frontend/src/public/js/mesh.js`) uses a custom unlit `ShaderMaterial`
(nearest-neighbor sampling, mipmaps disabled — bilinear/mip blending would bleed a tile's edge
pixels into its atlas neighbor) whose fragment shader `fract()`s the tile-relative UV to repeat a
single atlas tile across a merged quad, and falls back to the plain vertex color per-fragment when
`atlasRect` is the `[0,0,0,0]` sentinel — this per-fragment branch is what makes the live viewer
strictly more capable than the exported glTF here (see below), and is what actually makes greedy
meshing (which merges many blocks into one quad) compatible with per-block texture tiling at all.
Falls back entirely to the pre-Phase-12 flat-color `StandardMaterial` if no atlas was ever
uploaded (fetch 404/error). **Not yet empirically verified against a real running worker + browser**
(no headless-GL environment available in this dev setup) — in particular `invertY`'s row-order
assumption against `atlas.rs`'s top-down PNG rows is unconfirmed, worth checking on first live
test, same "flagged, not yet live-tested" caveat this project already carries for the Xaero
waypoint format (Phase 4).
The client-side region-export mesher (`voxel-mesh.js`) gained the identical UV/atlasRect output
(ported by hand from `mesh.rs`, same pattern as `block-colors.js` mirroring `palette.rs` — see the
new `block-textures.js` mirroring `block_names.rs`), but **`gltf-export.js` deliberately does not
embed the atlas texture into exported glTFs** — still vertex-color-only, unchanged from before this
phase. Reason: standard glTF materials only support one fixed formula (`baseColorTexture *
baseColorFactor * COLOR_0`, no branching), so the live viewer's per-fragment vertex-color fallback
for untextured quads isn't expressible in a way that works in arbitrary external viewers (Blender,
generic glTF web viewers) — properly supporting it needs either a reserved always-white atlas tile
baked into `atlas.rs` or splitting merged geometry into per-material primitives, both real scope,
deliberately deferred rather than shipping some faces textured and others visibly wrong.
**Still not done** (see the plan's phase list): real non-cube block/blockentity geometry via
blockstate/model JSON parsing (`BlockAssetExtractor`'s texture matching is still a filename
convention guess, not a real model resolution) — renumbered to **Phase 13** once the atlas/UV
scope above turned out to be its own full phase; Thaumcraft remains the named stress test for it.
### Real (non-cube) block models (Phase 13)
`worker/src/models.rs` is a real blockstate/model JSON resolver: given a block's registry name, it
picks a variant, walks the model's `parent` chain (merging `textures` maps as it goes, child
overrides win), and resolves each element's face textures down to the same atlas-lookup key
`atlas.rs` already uses (Phase 12) — so a resolved non-cube model's faces are textured with zero
atlas-side changes. `ResolvedModel::is_full_cube()` tells `mesh.rs` whether a block still belongs
on the existing cube greedy-mesher (untouched — lower risk, and re-rendering a plain cube through
the new per-element path would be pure overhead) or needs its own per-element geometry.
Two model sources, deliberately split:
- **Vanilla**: extracted from the same Mojang client jar Phase 11/12 already download (cached to
disk under its own `vanilla-<version>-models.json`, a third instance of the same accepted
"duplicate download, cached after first build" tradeoff as the palette/atlas), worker-wide.
- **Modded**: shipped by the mod over a new `block_models` WS message (mirrors Phase 11's
`block_textures`, batched the same way) and stored per-server in a new `block_models` Postgres
table (`server_id, kind, name, json`, `kind` distinguishing a blockstate entry from a model entry
since both are keyed by resource-location-shaped strings that could otherwise collide) — fetched
fresh per chunk-job by the worker, alongside the already-existing `block_registry` table (Phase
11 wrote it but nothing ever read it back until now). A modded model's `parent` can point at a
vanilla base model (e.g. `"minecraft:block/cross"`) via `ModelRegistry::resolve`'s `fallback`
parameter — common in practice, since plenty of modded blocks just extend a vanilla shape.
**Deliberately out of scope**, documented in `models.rs`'s doc comments rather than silently
dropped:
- **No `multipart` blockstates** (fences, walls, redstone wire, glass panes) — there's no way to
know a block's neighbor-dependent connection state from this project's raw block-id/meta data
model, so a multipart-only blockstate resolves to `None` and the block falls back to a flat cube,
same as pre-Phase-13.
- **No property-based variant selection** — chunk data here only ever carries a numeric `meta`
(legacy) or a truncated packed state id (modern, per Phase 10), never named property strings, so
`resolve()` always picks a deterministic representative variant (the `""` key if present, else
alphabetically first) rather than the "correct" one for a given block's actual state.
- **No per-face UV rectangle or per-variant rotation** — element geometry (`from`/`to`) is real,
but face texturing reuses the same tile-relative "UV span in block units" scheme the cube mesher
already uses, not the model's literal declared UV rect, to avoid needing per-pixel atlas
remapping/a more complex shader.
- Weighted multi-model variant lists always take the first entry (weights ignored); `meta` is never
consulted for modded block resolution, since `block_registry` only carries `block_id -> name`
(no per-state granularity) — a real, pre-existing schema constraint, not new to this phase.
A genuine pre-existing rendering bug got fixed as a side effect: `compute_face_masks` treated *any*
non-zero block as solid for neighbor face-culling, so a torch (or any non-cube block) sitting next
to a solid block incorrectly culled that solid block's adjacent face. Excluding non-cube-resolved
voxels from the cube mesher's input array (needed anyway, for Phase 13's own correctness) fixes
this for free — see `mesh.rs`'s `a_non_cube_neighbor_no_longer_incorrectly_culls_an_adjacent_solid_faces`
test.
Verified via real `cargo build`/`cargo test --lib` (worker, 65/65 passing, up from 52) and a real
`./gradlew :forge-1_12_2:compileJava` against the actual legacy ForgeGradle toolchain for the mod
side. `api`'s new `models.test.ts` (mirrors `textures.test.ts`'s pattern) has since been run against
a real throwaway Postgres — 4/4 passing — once Docker connectivity was fixed (the dev machine's real
`dockerd` runs inside WSL2, not Docker Desktop; Windows' `DOCKER_HOST` had nothing listening on the
other end until a systemd drop-in exposed it on `tcp://127.0.0.1:2375`). Full `api` (98/98) and
`frontend` (39/39) suites both pass against real Postgres/Redis/MinIO, closing the same "Docker
unavailable" gap Phase 11 had also carried (see "Running tests" below).
## Running
```
docker compose up
```
`api` on :3000, `frontend` on :3001, both behind Caddy on :80. `api` applies its Postgres
migrations on startup (see `api/src/db/migrate.ts`); `worker` consumes the `mcmapper:dirty-chunks`
Redis stream via a consumer group (`mcmapper-workers`) so multiple instances split work safely —
scale locally with `docker compose up --scale worker=N`, or run a standalone `worker` container
on separate hardware pointed at the same Postgres/Redis/MinIO over a private network (VPN/
Tailscale/LAN — never expose those ports publicly).
`worker`'s `RENDER_THREADS`/`RENDER_PROFILE` (see `worker/.env.example`) size the rayon pool that
renders a batch of dirty chunks in parallel and how many chunks are pulled off the stream per
batch: `RENDER_THREADS=auto` uses all available cores, or set a fixed count; `RENDER_PROFILE`
is `server` (default — many small batches, tuned for a many-core box) or `consumer` (fewer,
larger batches, less scheduling overhead on fewer/faster cores).
### Connecting a mod instance
Register a server via the admin panel at `/admin` (set `MCMAPPER_ADMIN_TOKEN` first — see
"Admin panel" below), or seed one row by hand for scripted/headless setup:
```
docker compose run --rm api bun run seed
```
reads `MCMAPPER_SEED_SERVER_NAME`/`MCMAPPER_SEED_SERVER_TOKEN` from `api/.env.example` (edit
those first, or override with `-e`). Either way, point the mod's `MapperConfig#serverToken` at
the resulting token. Once the mod connects and sends its initial chunk backfill, tiles appear at
`GET /api/tiles/:serverId/:dimension/:zoom/:tileX/:tileY.png` (zoom is always `0` for now — see
`worker/src/render/cpu.rs`) and the frontend's Leaflet viewer picks them up automatically from
`GET /api/servers`.
### Admin panel
`/admin` (linked from the map page's header) manages the server registry: register new servers
(generates their token — never admin-supplied, so it can't collide with or be guessed from
anything else), and edit `authMode`, `anonymousChatAllowed`, `waypointFormat`, and
`playerPositionsVisible` per server. It's gated behind a single shared secret, not a per-account
role (this is a single-operator backend) — set `MCMAPPER_ADMIN_TOKEN` in `api/`'s untracked
`.env` (see `api/.env.example`), restart `api`, then enter that same value into the panel's
unlock prompt. Leaving it unset disables every `/api/admin/*` route (401), it does not default to
open. The token is remembered in the browser's `localStorage` after unlocking, same pattern as
the player-facing session token (see `api/src/link.ts`'s doc comment).
Settings not exposed here yet (which map types render, dimension filtering) don't have underlying
features built for them either — no point in a knob nothing reads. They'll gain admin UI
alongside the feature that needs them.
### Player positions (Phase 7b)
The mod sends a throttled roster of online players (`player_positions` over `/ws`, see
`api/src/ws-gateway.ts`'s doc comment) — its own `playerTrackingEnabled`/
`playerPositionIntervalTicks` config decides whether/how often it sends this at all. The backend
relays it to any browser subscribed on `/ws/players/:serverId` (`api/src/players-gateway.ts`),
gated per-server on the admin panel's `playerPositionsVisible` toggle (default on) — an
independent, backend-side "should we show it" decision from the mod's own config. Not persisted
(no meaningful history for a live position), just the latest roster in Redis (`api/src/players.ts`)
so a browser tab that connects between mod flushes doesn't sit empty. The map renders players as
map markers with a `show`/hide toggle and an online count, right above the region-export panel.
## Running tests
`worker`'s tests (`cargo test`, in `worker/`) are pure unit tests (greedy mesher, tile
rasterizer, block-color palette) and need nothing running — including the `gpu`/`hybrid` backend
tests (Phase 8), which request a real GPU adapter and skip themselves (rather than failing) if
none is found, so `cargo test` still passes on a machine with no GPU. `api`'s and `frontend`'s (`bun test`,
in each directory) are integration tests against real infra — start it first:
```
docker run -d --name mcmapper-test-pg -e POSTGRES_USER=mcmapper -e POSTGRES_PASSWORD=mcmapper -e POSTGRES_DB=mcmapper -p 15432:5432 postgres:17-alpine
docker run -d --name mcmapper-test-redis -p 16379:6379 redis:7-alpine
docker run -d --name mcmapper-test-minio -e MINIO_ROOT_USER=mcmapper -e MINIO_ROOT_PASSWORD=mcmapper-dev-only -p 19000:9000 minio/minio:latest server /data
cd api && DATABASE_URL=postgres://mcmapper:mcmapper@localhost:15432/mcmapper bun run migrate
```
then, from `api/` or `frontend/`:
```
DATABASE_URL=postgres://mcmapper:mcmapper@localhost:15432/mcmapper \
REDIS_URL=redis://localhost:16379 \
MINIO_ENDPOINT=localhost MINIO_PORT=19000 MINIO_ACCESS_KEY=mcmapper MINIO_SECRET_KEY=mcmapper-dev-only \
bun test
```
Each test file creates and tears down its own server row (random token per run) so runs never
collide with each other or with real dev data — see `api/src/test-helpers.ts`.
## Running the e2e suite
`bun test` above exercises `api` and `frontend` independently — it never proves the browser can
actually reach both through the same origin the way production's Caddy routing does (`/ws*` and
`/api/*` -> `api`, everything else -> `frontend`, see `Caddyfile`). `e2e/` is a standing
Playwright suite that closes that gap: it drives a real Chromium browser against the full stack
behind a small routing-equivalent proxy (`e2e/proxy.ts` — no `caddy` binary is available in this
dev environment, so it isn't real Caddy, just the same three routing rules).
```
cd e2e
bun install
bunx playwright install chromium # one-time, downloads the browser binary
bunx playwright test
```
`global-setup.ts` does everything by itself — no manual container/migration steps needed first
(unlike the `bun test` section above): throwaway Postgres/Redis/MinIO containers
(`mcmapper-e2e-*`, distinct names/ports from the `bun test` ones so both can run at once),
migrations, a seeded server + linked account/session + a 5x5-chunk terrain footprint around the
world origin, then the `api`/`frontend`/proxy processes (the `api` process is started with a
fixed `MCMAPPER_ADMIN_TOKEN` for `tests/admin.spec.ts` to use — see `config.ts`'s `ADMIN_TOKEN`).
`global-teardown.ts` kills every spawned process and removes the containers afterward. Covers the
UI flows most worth a real click-through: the marker click-to-place/edit popup
(`tests/markers.spec.ts`, including that a marker created while linked shows up in a second
browser context with the same session — the cross-device sync claim), the region-select drag +
glTF export (`tests/region-export.spec.ts`, including a real triggered file download), the
admin panel's token gate + server register/edit/delete round trip (`tests/admin.spec.ts`), and
the player-position relay (`tests/players.spec.ts` — simulates a mod connection over the real
`/ws` protocol from inside the browser context and confirms a subscribed tab renders the roster,
respects the `show` toggle, and clears markers on an empty roster).
## Attribution
See `THIRD_PARTY_NOTICES.md`.