Compare commits

...

12 Commits

Author SHA1 Message Date
octoturge 6e2739672c Add itzg-based docker-compose for live-testing the mod against real MC servers
Three services (mc-1_12_2 default, mc-1_7_10/mc-neoforge-26_1 opt-in via
Compose profiles) using itzg/docker-minecraft-server, with mods/config bind-
mounted per loader/version (mods/<loader>/<version>/) so jars can be dropped
in and swapped without touching the compose file. Verified against real
containers: fixed a WSL docker credential-helper misconfig blocking pulls,
and pinned legacy Forge services to the java8 image tag after itzg's
:latest (Java 25) crashed LaunchWrapper with a ClassCastException — mc-1_12_2
now boots cleanly to "Done" on java8.
2026-08-10 06:08:49 +02:00
octoturge 376d2d0507 Phase 13: forge-1_12_2 ships blockstate/model JSON for non-cube models
BlockAssetExtractor#extractModels walks every active mod's own
jar/directory (Loader#getActiveModList's ModContainer#getSource, not
the classloader, since directory listing isn't a classloader
operation) for every blockstates/*.json and models/block/**/*.json
file, shipping all of them over a new batched block_models WS message
alongside the existing Phase 11 block_registry/block_textures dump.
Resolution of this data into real non-cube geometry is entirely
backend-side (see MCMapper-Backend's worker/src/models.rs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
2026-08-10 01:19:36 +02:00
octoturge 47490309fe Phase 11: extract and ship block textures/registry (forge-1_12_2 leaf)
Forge doesn't split client/server jars, so a modded server's own classpath
already has every loaded mod's block textures, unused server-side.
BlockAssetExtractor reads them via the classloader (best-effort convention
match on registry name -> texture filename) plus the numeric-id -> registry-
name mapping, and ships both to the backend once per connection via two new
wire messages (block_registry, block_textures), fired through a new
BackendConnection#setReadyListener callback so the one-time send can't race
the async WS handshake. Only forge-1_12_2 wires it up so far; the other two
leaves can adopt it later with no protocol change.
2026-08-09 23:20:53 +02:00
octoturge 52417a7b92 Phase 10: implement neoforge-26_1 leaf (WS, deltas, chat, link, reconciliation, player tracking)
Ports the full feature set proven by the two legacy leaves to modern, post-Flattening
NeoForge (loader assumption confirmed: NeoForge 26.1.2.94 is real and published).
Neoforge261ChunkAdapter reads BlockState via LevelChunk/LevelChunkSection instead of raw
id+meta, carrying the full packed state id in DeltaEvent#blockStateId (a lossless int,
unlike the pre-Flattening 16-bit encoding) while documenting a known truncation caveat for
SectionData's char[]-based 3D backfill on very large modded registries. MCMapperMod uses
constructor-injected event buses (IEventBus/ModContainer) and NeoForge.EVENT_BUS instead of
@Mod.EventHandler methods, ModConfigSpec instead of legacy Configuration, and LinkCommand is
a Brigadier registration (no CommandBase in this era) fired from RegisterCommandsEvent.
Tracks its own loaded-chunk set via ChunkEvent.Load/Unload rather than querying chunk
provider internals (no stable public API for that in modern MC).

Gets its own standalone Gradle wrapper + settings.gradle (Foojay toolchain resolver) since
ModDevGradle needs Gradle 8+ and a Java 25 toolchain (MC itself now requires Java 25),
incompatible with the legacy leaves' Gradle-7/JDK-8 pin in one invocation.

Verified against real NeoForge 26.1.2.94 + decompiled MC 26.1.2 source via
./gradlew build from inside neoforge-26_1/ (two real API mismatches caught and fixed by
the compiler: ChunkPos is now a record — x()/z() methods, not fields — and
ResourceLocation was renamed to Identifier, ResourceKey#identifier() not #location()).
2026-08-09 22:44:06 +02:00
octoturge 0d8e670cd8 Phase 9: implement forge-1_7_10 leaf (WS, deltas, chat, link, reconciliation, player tracking)
Ports the full feature set proven by forge-1_12_2 (Phases 1-2-3-7-7b) to MC 1.7.10's
older, pre-block-state Forge/FML generation: Forge1710ChunkAdapter reads raw Block+meta
via Chunk/ExtendedBlockStorage instead of IBlockState, hooks BlockEvent/ChunkEvent/TickEvent
under cpw.mods.fml, and MCMapperMod/LinkCommand/Forge1710ChatBridge adapt to 1.7.10's
CommandBase/ChatComponentText/ServerConfigurationManager API shapes. No MultiPlaceEvent hook
at this Forge version (falls back to the reconciliation sweep for multi-block placements).
Verified against real Forge 10.13.4.1614-1.7.10 via ./gradlew :forge-1_7_10:build.
2026-08-09 22:13:45 +02:00
octoturge 890ce46c4f Update README for Phase 7b and fix stale Phase 7 reference
Reconciliation sweep already shipped in 52583f2; document the new
playerTrackingEnabled/playerPositionIntervalTicks config options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
2026-08-09 20:06:48 +02:00
octoturge ef47d4481a Add throttled player-position tracking, mod side (Phase 7b)
Adds PlayerPosition (common/protocol) and BackendConnection.sendPlayerPositions(),
wired into MCMapperMod.java: a new playerPositionIntervalTicks-throttled tick timer
(gated by playerTrackingEnabled) sends the full current overworld online-player
roster to the backend each interval, mirroring the "current state, not a diff"
philosophy of columns/sections — a player logging out just stops appearing next
send, no separate leave message needed.

Wire message: {"type":"player_positions","dimension":0,"players":[...]}.
Backend-side receive/relay + admin visibility toggle land in a follow-up commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
2026-08-09 19:38:16 +02:00
octoturge 52583f2056 Add periodic reconciliation sweep (Phase 7)
Adds a rotating, non-overlapping-batch ReconciliationScheduler (common/,
loader-agnostic) and wires it into the 1.12.2 leaf: a new tick timer
(reconciliationIntervalTicks, mirroring the existing delta-flush timer)
periodically re-reads and resends a bounded slice of currently-loaded
chunks, catching mutations that never fire a block event (world-gen,
other mods writing blocks directly, /fill, etc.).

ChunkAdapter gains loadedChunkKeys(); Forge1122ChunkAdapter implements it
via ChunkProviderServer.getLoadedChunks(). MapperConfig gains
reconciliationChunksPerSweep to bound sweep cost.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
2026-08-09 19:30:32 +02:00
octoturge 41034356ac Phase 4: receive waypoint shares and render JourneyMap/Xaero chat links
DefaultBackendConnection handles the new waypoint_share message and
dispatches it through a WaypointShareListener. WaypointChatFormatter
builds the actual chat text for both formats from publicly documented
wire formats (see THIRD_PARTY_NOTICES.md for sources/attribution) —
neither needs a click-event component, since both client mods
auto-detect the right plain-text shape. Forge1122ChatBridge wires this
into a real broadcast; MCMapperMod registers the listener.

Built test-first per the project's TDD workflow; verified live against
the real backend, including a byte-exact JourneyMap-format chat
message produced from a real WS payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
2026-08-08 19:21:30 +02:00
octoturge 73e10d34e5 Phase 3: /mcmapper link command and two-way chat bridge
Test-first from here on (per request after Phase 2's backend commit):
LinkCodeGenerator has a standalone test (common/src/test) written and
confirmed failing before the implementation existed.

common: LinkCodeGenerator produces a 6-character code from an
unambiguous charset (excludes 0/O/1/I/L — it gets read off a chat line
and typed back). BackendConnection grows sendLinkRequest (now actually
implemented, was a Phase 1 stub), sendChatMessage, and setChatListener/
ChatListener for inbound web->game chat, all wired into
DefaultBackendConnection's existing JSON wire protocol.

forge-1_12_2: LinkCommand (/mcmapper link) generates a code, resolves
online/offline from the server's actual auth mode
(MinecraftServer#isServerInOnlineMode), and shows it to the player.
Forge1122ChatBridge implements the inbound half (injects web chat into
real in-game chat via the player list) — injectWaypointShare is a
documented Phase 4 stub, same pattern as sendLinkRequest was in Phase 1.
MCMapperMod hooks ServerChatEvent to forward in-game chat out and wires
the chat listener to the bridge.

Verified end-to-end against a live MCMapper-Backend instance through the
real Java client (not a stand-in): a mod-generated link code correctly
redeems via the backend's HTTP endpoint to an account with the mod-
supplied username, and a browser chat message correctly round-trips all
the way to the mod's live ChatListener callback.
2026-08-08 17:09:09 +02:00
octoturge ab94cca1b2 Document common test runner as 'bash run-tests.sh' (executable bit doesn't survive this filesystem's git add) 2026-08-08 16:40:33 +02:00
octoturge 53cba762b4 Add standalone test coverage for common/'s MiniJson
Requested after MCMapper-Backend's Phase 2: development follows TDD
(test-first) from here on — this retrofits the piece already built
(MiniJson, the hand-rolled JSON codec) before that request landed.

No JUnit/Gradle involved, matching common/'s "must compile standalone
under plain javac" constraint (see README.md) — a hand-rolled assertion
runner in src/test/java, compiled and run via the new run-tests.sh.
Covers write escaping, nested object/array encoding, parsing (including
the exact columns-message shape DefaultBackendConnection actually
builds), and a write-then-parse round-trip.

Deliberately not wired into either leaf's build.gradle sourceSets (which
only pull in src/main/java) — verified both forge-1_12_2 and forge-1_7_10
still compile with src/test/ present, confirming test code doesn't leak
into the shipped mod jar.
2026-08-08 16:39:56 +02:00
50 changed files with 3158 additions and 43 deletions
+107 -12
View File
@@ -14,10 +14,16 @@ in Claude Code's plan mode.
- `forge-1_12_2/`**primary leaf**, MC 1.12.2 Forge. First implemented; this is the actual
driving use case (an Enigmatica 2 modpack server). Builds with legacy ForgeGradle 2.3 via
anatawa12's Gradle-7-compatible fork (see `THIRD_PARTY_NOTICES.md`).
- `forge-1_7_10/` — MC 1.7.10 Forge. Second priority. Builds with legacy ForgeGradle 1.2, same
fork family, one Forge-tooling generation further back.
- `neoforge-26_1/` — MC 26.1.2 (NeoForge, assumed). Lowest MVP priority. Structurally scaffolded,
not yet wired into the default build (see `settings.gradle`).
- `forge-1_7_10/` — MC 1.7.10 Forge (Phase 9). Second priority, fully wired against the same
`common` interfaces as `forge-1_12_2`. Builds with legacy ForgeGradle 1.2, same fork family, one
Forge-tooling generation further back — pre-block-state (raw `Block` + metadata int, no
`IBlockState`) and pre-FML-repackage (`cpw.mods.fml.*`, not `net.minecraftforge.fml.*`).
- `neoforge-26_1/` — MC 26.1.2 (Phase 10; loader confirmed NeoForge). Lowest MVP priority, fully
wired against the same `common` interfaces as the two legacy leaves. Post-Flattening, mixin-era
API: block reads are `BlockState`, not raw id+meta; events/commands/config live under
`net.neoforged.*` with constructor-injected event buses instead of `@Mod.EventHandler` methods
and Brigadier commands instead of `CommandBase`. Builds standalone via ModDevGradle — see
"Building" below, not part of the root multi-project build (see `settings.gradle`).
## Version targets and priority
@@ -38,12 +44,28 @@ Both legacy leaves share one root build, on Gradle 7.6 / JDK 8 (pinned via
```
The first build downloads Minecraft/Forge artifacts and MCP mappings from Forge's Maven and can
take a while / needs network access. `neoforge-26_1` is not yet buildable from the root — it
needs Gradle 8+ and JDK 17+ (ModDevGradle), incompatible with the legacy leaves' toolchain
within one Gradle invocation; see `settings.gradle`'s comment for the workaround until Phase 10
gives it a proper isolated build.
take a while / needs network access.
## Configuration (Phase 1: forge-1_12_2)
`neoforge-26_1` is **not** part of the root build — it needs Gradle 8+ and a Java 25 toolchain
(Minecraft itself now requires Java 25 as of the 26.x cycle), incompatible with the legacy
leaves' Gradle-7/JDK-8 pin within one Gradle invocation. It has its own wrapper; build it from
inside its own directory:
```
cd neoforge-26_1
./gradlew build
```
The Gradle *daemon* can run on any modern JDK on `PATH`/`JAVA_HOME` (or pinned via
`org.gradle.java.home` in a `$GRADLE_USER_HOME/gradle.properties`, same mechanism as the legacy
leaves' JDK 8 pin — see `settings.gradle`'s comment) — it does not need to already be JDK 25
itself. `settings.gradle` applies the Foojay toolchain resolver so Gradle auto-provisions an
actual JDK 25 (into its own `GRADLE_USER_HOME` cache, not a system-wide install) for the
compile/run tasks. The first build also downloads and decompiles/patches Minecraft itself via
NeoForge's NeoForm pipeline (the modern equivalent of the legacy leaves' MCP step) — expect it to
take several minutes and a real chunk of disk/network the first time; subsequent builds are fast.
## Configuration (all three leaves)
On first server start the leaf writes `config/mcmapper.cfg` with defaults. Set:
@@ -54,11 +76,84 @@ serverToken=<token from MCMapper-Backend's `bun run seed`>
then restart. With those set, the mod connects to the backend, backfills already-loaded
overworld chunks, and streams event-driven column deltas (block place/break) in batches every
`deltaFlushIntervalTicks` (default 20 = 1s). No periodic reconciliation sweep yet (Phase 7),
and only the overworld — other dimensions and `forge-1_7_10`/`neoforge-26_1` land in later
phases. The WS client and JSON encoding are hand-rolled (no third-party dependency) — see
`deltaFlushIntervalTicks` (default 20 = 1s). A periodic reconciliation sweep (Phase 7) also
walks a bounded number of currently-loaded chunks per tick (`reconciliationChunksPerSweep`,
default 4) and re-sends any that drifted from what the backend last acknowledged, to catch
mutations event hooks miss (world-gen, other mods, `/fill`). Only the overworld is tracked. The
WS client and JSON encoding are hand-rolled (no third-party dependency) — see
`common/src/main/java/.../ws/SimpleWebSocketClient.java` and `.../json/MiniJson.java` for why.
`forge-1_7_10` and `neoforge-26_1` implement the identical config/flush/reconciliation/
player-tracking behavior against their own API generation (see `Forge1710ChunkAdapter`'s and
`Neoforge261ChunkAdapter`'s javadocs for what differs). Neither has a `MultiPlaceEvent`-equivalent
hook wired (1.7.10: that class doesn't reliably exist at this Forge version; 26.1.2: skipped for
symmetry, no strong need identified), so multi-block placements like doors/beds are only caught
by the reconciliation sweep rather than immediately, unlike `forge-1_12_2`. `neoforge-26_1` writes
its config to `config/mcmapper-server.toml` (NeoForge's `ModConfigSpec`/TOML format, not the
legacy leaves' `.cfg`), and — being post-Flattening — carries the full registry-wide packed
`BlockState` id as `DeltaEvent#blockStateId` (an `int`, so this is lossless for the 2D column
pipeline); 3D section backfill (`SectionData#blocks`, a `char[]` for wire-size reasons inherited
from the pre-Flattening leaves) truncates to the low 16 bits, a known, documented collision risk
for very large modded registries — see `Neoforge261ChunkAdapter`'s javadoc.
### Player position tracking (Phase 7b)
When `playerTrackingEnabled` (default `true`), the leaf sends a throttled roster of online
overworld players (`{"type":"player_positions",...}`, see `DefaultBackendConnection`'s
class-level wire-protocol doc comment) every `playerPositionIntervalTicks` (default 40 = 2s) —
always the *full current roster*, not a diff, so a logged-out player simply stops appearing in
the next send. This is a mod-local toggle only: whether the backend actually relays positions on
to web viewers is a separate, independent per-server admin setting on the backend side (see
MCMapper-Backend's README) — a server operator can track positions server-side without exposing
them publicly, or vice versa.
### Real block textures (Phase 11)
Forge doesn't split client/server jars, so a dedicated server's own classpath already has every
loaded mod's `assets/<modid>/textures/blocks/*.png` (or `textures/block/` on newer conventions),
sitting there unused server-side. `forge-1_12_2` (this project's primary, most-modded target —
Enigmatica 2) reads them once at server-starting time via `BlockAssetExtractor`: `Block.REGISTRY`
gives every block's numeric id + registry name (same source as the existing column/section wire
encoding), and each block's texture is guessed by a best-effort *convention* match — the registry
name's path segment tried as a texture filename — not a real blockstate/model JSON resolution
(that's Phase 12's job; a block whose model doesn't follow the convention is just skipped, no
worse than before this phase). The registry mapping (`block_registry`) and extracted PNGs
(`block_textures`, batched 50/message) are sent to the backend once, right after the WS handshake
completes — via `BackendConnection#setReadyListener`, since `connect()` is async and a naive send
right after calling it would silently no-op before the handshake lands.
`forge-1_7_10` and `neoforge-26_1` don't implement extraction yet — `sendBlockRegistry`/
`sendBlockTextures`/`setReadyListener` live on the shared `BackendConnection` interface (so either
leaf can adopt them later with no protocol change) but only `forge-1_12_2` calls them so far,
matching this phase's Enigmatica-2-focused scope. See MCMapper-Backend's README for what the
backend currently does with this data (short version: stores it; per-server render-time
resolution is explicitly deferred, documented there).
### Real (non-cube) block models (Phase 13)
`BlockAssetExtractor#extractModels` ships every `assets/<modid>/blockstates/*.json` (flat, matching
vanilla's own layout — no subfolders expected) and `assets/<modid>/models/block/**/*.json`
(recursive — mods are free to nest these, e.g. a shared `models/block/base/` folder) file for every
currently-active mod, keyed the way MCMapper-Backend's `worker/src/models.rs` expects. Unlike
`extractTextures`, this can't use the classloader (there's no `ClassLoader` API to list a
directory's contents, only to read one known file by path) — it walks each mod's own source
instead, via `net.minecraftforge.fml.common.Loader#getActiveModList`'s `ModContainer#getSource`,
handling both shapes that can show up there: a packed jar (opened as a `ZipFile`) in production, or
a raw exploded directory when running from an IDE/dev environment.
The mod deliberately ships **every** model file it finds under a mod's `models/block/` tree rather
than trying to work out which ones a given blockstate actually references — a blockstate's
`"model"` field (or another model's `"parent"`) can point at a path that doesn't match the
referencing block's own registry path 1:1 (e.g. several blocks sharing one base model), and the
mod has no JSON parser to resolve that itself. That resolution — parent-chain walking, texture-
variable substitution, picking a representative variant — is entirely backend-side (see
MCMapper-Backend's README's own Phase 13 section for what it does and doesn't handle, e.g. no
`multipart` blockstate support, no property-based variant selection).
Sent as a new `block_models` WS message, batched 50/message like `block_textures`, from the same
ready-listener callback as the Phase 11 registry/texture dump. Verified via a real
`./gradlew :forge-1_12_2:compileJava` against the pinned legacy ForgeGradle toolchain.
## Attribution
See `THIRD_PARTY_NOTICES.md`.
+24
View File
@@ -21,6 +21,30 @@ publicly documented/community-reverse-engineered wire format, never from Xaero's
- What was adapted: not adapted/copied — used as-is as a build-tool dependency (Gradle plugin),
same rationale as the FG2.3 fork above, one Forge-tooling generation further back for 1.7.10.
## JourneyMap chat-waypoint bracket syntax
- Source: https://github.com/1whohears/JourneyMapQOL_1.7.10, corroborated by
https://github.com/TeamJM/journeymap/issues/465 ("Waypoint Chat option")
- License: MIT (JourneyMapQOL_1.7.10)
- Used in: `common/src/main/java/com/octoturge/mcmapper/common/protocol/WaypointChatFormatter.java`
(`journeyMapText`)
- What was adapted: not code — the documented plain-text convention JourneyMap itself
auto-detects in chat (`[x:..,y:..,z:..,dim:..,name:..,color:..,delete:..]`), reimplemented as
our own string builder from the publicly described field list.
## Xaero's Minimap `xaero-waypoint:` chat-share schema (community-reverse-engineered)
- Source: https://gist.github.com/macimas/937a392be075b1bce7a2ae69ea933ef5 ("my rough
interpretation on xaero-waypoint schema formatty")
- License: none stated (personal gist notes) — used only as a documentation reference for an
otherwise-undocumented wire format, no code copied
- Used in: `common/src/main/java/com/octoturge/mcmapper/common/protocol/WaypointChatFormatter.java`
(`xaeroText`)
- What was adapted: not code — the documented nine-field colon-separated schema
(`name:marker:x:y:z:color:use_yaw:yaw:dimension`), reimplemented as our own string builder.
Xaero's Minimap/Worldmap are closed-source and this format has no official documentation, so
this is flagged in the formatter's javadoc as best-effort/unverified — worth testing against a
real Xaero install before relying on it, since even community sources disagree on field count
for this specific format.
Further entries will be added here as more third-party material lands, in the form:
```
+17
View File
@@ -17,3 +17,20 @@ Minecraft/Forge/NeoForge API usage — it must compile standalone under plain `j
request/response). Mirrors the WS protocol described in the root plan.
- `config/` — the common config model (backend URL, server token, tracking/reconciliation
intervals) each leaf loads via its own loader-specific config system.
- `json/`, `ws/` — the hand-rolled JSON codec and RFC 6455 WS client `DefaultBackendConnection`
is built on (no third-party dependency, for the same "no shading through legacy ForgeGradle"
reason this module stays dependency-free generally).
## Testing
```
bash run-tests.sh
```
Compiles `src/main/java` + `src/test/java` and runs every `*Test.java` class's `main()`. No
JUnit/Gradle — same "must compile standalone under plain `javac`" constraint as the module
itself, and test code never ships in the mod jar so it doesn't need to match either leaf's JDK 8
target. Anything needing a live Forge/Minecraft world (event hook wiring, world reads) isn't
unit-testable this way — those stay integration-tested against a real running
MCMapper-Backend instance instead (see the Phase 1/2 commit messages for how that's been done
so far).
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Compiles and runs common/'s standalone test classes (no JUnit, no Gradle — see README.md for
# why: common/ must compile under plain javac, independent of either leaf's ForgeGradle
# toolchain, and test code never ships in the mod jar so it doesn't need to match the leaves'
# JDK 8 target either). Any JDK on PATH works.
set -euo pipefail
cd "$(dirname "$0")"
OUT=$(mktemp -d)
trap 'rm -rf "$OUT"' EXIT
javac -d "$OUT" $(find src/main/java src/test/java -name '*.java')
status=0
for class in $(find src/test/java -name '*Test.java' | sed 's#src/test/java/##; s#\.java$##; s#/#.#g'); do
echo "== $class =="
java -cp "$OUT" "$class" || status=1
echo
done
exit $status
@@ -1,8 +1,13 @@
package com.octoturge.mcmapper.common;
import com.octoturge.mcmapper.common.protocol.BlockModelFile;
import com.octoturge.mcmapper.common.protocol.BlockRegistryEntry;
import com.octoturge.mcmapper.common.protocol.BlockTexture;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.LinkRequest;
import com.octoturge.mcmapper.common.protocol.PlayerPosition;
import com.octoturge.mcmapper.common.protocol.SectionData;
import com.octoturge.mcmapper.common.protocol.WaypointShare;
import java.util.List;
@@ -11,8 +16,9 @@ import java.util.List;
* modules (pure Java, no Minecraft API usage) — a docker-network hostname, LAN IP, or public
* domain in {@code MapperConfig#backendUrl} all work identically.
*
* Real implementation ({@link DefaultBackendConnection}) landed in Phase 1 (column deltas) and
* grew {@link #sendSections} in Phase 2 (full-voxel 3D mesh backfill).
* Real implementation ({@link DefaultBackendConnection}) landed in Phase 1 (column deltas), grew
* {@link #sendSections} in Phase 2 (full-voxel 3D mesh backfill), and grew chat/linking in
* Phase 3.
*/
public interface BackendConnection {
void connect(String url, String serverToken);
@@ -23,8 +29,64 @@ public interface BackendConnection {
void sendLinkRequest(LinkRequest request);
/** Forwards one in-game chat message to the backend — see ChatBridge.OutboundSink's javadoc. */
void sendChatMessage(String uuid, String username, String message);
/**
* Sends the full current online-player roster for one dimension (Phase 7b) — see
* {@link PlayerPosition}'s javadoc for why this is always the whole roster, not a diff. An
* empty list is a meaningful, intentional send (everyone logged out), so callers should call
* this every throttle tick rather than skipping it when there are no players.
*/
void sendPlayerPositions(int dimension, List<PlayerPosition> players);
/**
* Sends this leaf's numeric-blockId -&gt; registry-name dump (Phase 11) — see {@link
* BlockRegistryEntry}'s javadoc. Leaves that don't implement Phase 11 texture extraction
* (see {@link #sendBlockTextures}) can simply never call this; an empty/never-called dump is
* harmless, not an error, on the backend side.
*/
void sendBlockRegistry(List<BlockRegistryEntry> entries);
/**
* Sends this leaf's best-effort classloader-extracted block textures (Phase 11) — see {@link
* BlockTexture}'s javadoc. Implementations should batch calls (many small messages rather
* than one huge frame) rather than requiring the caller to pre-batch.
*/
void sendBlockTextures(List<BlockTexture> textures);
/**
* Sends this leaf's raw blockstate/model JSON dump (Phase 13) — see {@link
* BlockModelFile}'s javadoc. Implementations should batch calls the same way {@link
* #sendBlockTextures} does, for the same heavily-modded-pack reason.
*/
void sendBlockModels(List<BlockModelFile> files);
/**
* Registers a callback fired every time the connection successfully authenticates (including
* after an automatic reconnect) — {@code connect()} itself is async (the real handshake
* completes on a later WS frame), so callers with a one-time "just after connecting" send
* (see Phase 11's registry/texture dump) need this rather than calling right after {@code
* connect()}, which would silently no-op (every {@code send*} method is a no-op until ready).
*/
void setReadyListener(Runnable onReady);
/** Registers the callback for web-originated chat messages the backend relays back to us. */
void setChatListener(ChatListener listener);
/** Registers the callback for markers a web visitor shared to chat — see WaypointShare's javadoc. */
void setWaypointShareListener(WaypointShareListener listener);
void disconnect();
interface ChatListener {
void onChatMessage(String username, String message);
}
interface WaypointShareListener {
void onWaypointShare(WaypointShare share);
}
final class NoOp implements BackendConnection {
@Override
public void connect(String url, String serverToken) {
@@ -42,6 +104,38 @@ public interface BackendConnection {
public void sendLinkRequest(LinkRequest request) {
}
@Override
public void sendChatMessage(String uuid, String username, String message) {
}
@Override
public void sendPlayerPositions(int dimension, List<PlayerPosition> players) {
}
@Override
public void sendBlockRegistry(List<BlockRegistryEntry> entries) {
}
@Override
public void sendBlockTextures(List<BlockTexture> textures) {
}
@Override
public void sendBlockModels(List<BlockModelFile> files) {
}
@Override
public void setReadyListener(Runnable onReady) {
}
@Override
public void setChatListener(ChatListener listener) {
}
@Override
public void setWaypointShareListener(WaypointShareListener listener) {
}
@Override
public void disconnect() {
}
@@ -21,6 +21,14 @@ public interface ChunkAdapter {
/** Register the loader-specific hooks (block place/break, chunk load/unload) that feed the dirty buffer. */
void registerEventHooks(DeltaSink sink);
/**
* Currently-loaded chunk coordinates, packed as {@code (chunkX << 32) | (chunkZ & 0xFFFFFFFFL)}
* — the same packing the leaf modules' own dirty-chunk sets already use. Feeds
* {@link ReconciliationScheduler}, which picks a bounded rotating slice of this set to
* re-read and resend each periodic sweep (see the plan's "hybrid" change-detection decision).
*/
java.util.List<Long> loadedChunkKeys();
interface DeltaSink {
void onDelta(DeltaEvent event);
@@ -1,9 +1,14 @@
package com.octoturge.mcmapper.common;
import com.octoturge.mcmapper.common.json.MiniJson;
import com.octoturge.mcmapper.common.protocol.BlockModelFile;
import com.octoturge.mcmapper.common.protocol.BlockRegistryEntry;
import com.octoturge.mcmapper.common.protocol.BlockTexture;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.LinkRequest;
import com.octoturge.mcmapper.common.protocol.PlayerPosition;
import com.octoturge.mcmapper.common.protocol.SectionData;
import com.octoturge.mcmapper.common.protocol.WaypointShare;
import com.octoturge.mcmapper.common.ws.SimpleWebSocketClient;
import java.net.URI;
@@ -27,8 +32,29 @@ import java.util.function.Consumer;
*
* mod -&gt; api {"type":"columns","dimension":0,"columns":[{"x":..,"z":..,"height":..,"blockId":..,"blockMeta":..}]}
* mod -&gt; api {"type":"sections","dimension":0,"chunkX":..,"chunkZ":..,"sections":[{"sectionY":..,"blocks":"&lt;base64&gt;"}]}
* mod -&gt; api {"type":"link_request","code":"...","uuid":"...","username":"...","authMode":"online"}
* mod -&gt; api {"type":"chat","uuid":"...","username":"...","message":"..."}
* api -&gt; mod {"type":"chat","username":"...","message":"..."}
* api -&gt; mod {"type":"waypoint_share","name":"...","x":..,"y":..,"z":..,"dimension":..,"color":"#RRGGBB","format":"journeymap"|"xaero"}
*
* mod -&gt; api {"type":"player_positions","dimension":0,"players":[{"uuid":"...","username":"...","x":..,"y":..,"z":..}]}
*
* mod -&gt; api {"type":"block_registry","entries":[{"id":4000,"name":"botania:manapool"}]}
* mod -&gt; api {"type":"block_textures","textures":[{"name":"botania:manapool","dataBase64":"..."}]}
* mod -&gt; api {"type":"block_models","entries":[{"kind":"blockstate","name":"botania:manapool","json":"..."},{"kind":"model","name":"botania:block/manapool","json":"..."}]}
* </pre>
*
* Phase 3: {@code link_request} is sent by {@code /mcmapper link}; {@code chat} both directions
* is the in-game/web chat bridge (see {@link ChatBridge}) — inbound {@code chat} messages are
* delivered through whatever {@link ChatListener} the leaf registered via
* {@link #setChatListener}.
*
* Phase 4: {@code waypoint_share} is sent when a linked account shares a placed marker to chat
* (see MCMapper-Backend's {@code markers.ts}) — delivered through whatever
* {@link WaypointShareListener} the leaf registered via {@link #setWaypointShareListener}. The
* mod's job is just to turn the point into the right chat text (see
* {@link com.octoturge.mcmapper.common.protocol.WaypointChatFormatter}) and broadcast it.
*
* A "columns" message doubles as both initial backfill (one message per loaded chunk) and live
* deltas (one message per flush tick) — see {@link DeltaEvent}'s javadoc for how a
* {@code List<DeltaEvent>} maps onto it. {@code dimension} in {@link DeltaEvent} is a string so
@@ -42,8 +68,29 @@ import java.util.function.Consumer;
* last flush).
*
* No offline queue: deltas sent while disconnected are dropped rather than buffered — the
* periodic reconciliation sweep (not yet built, see plan's Phase 7) is what's meant to catch
* whatever a disconnect window missed, so buffering here would be solving the same problem twice.
* periodic reconciliation sweep (see {@link com.octoturge.mcmapper.common.ReconciliationScheduler})
* is what's meant to catch whatever a disconnect window missed, so buffering here would be
* solving the same problem twice.
*
* Phase 7b: {@code player_positions} carries the full current online-player roster for one
* dimension (not a diff), throttled on a separate timer from the delta flush — see
* {@link PlayerPosition}'s javadoc. The backend's per-server {@code playerPositionsVisible}
* admin toggle decides whether this gets relayed on to web viewers; it's independent of the
* mod-local {@code playerTrackingEnabled} config, which decides whether the mod computes/sends
* this at all.
*
* Phase 11: {@code block_registry}/{@code block_textures} are sent once, shortly after connecting
* (a world's numeric-id assignments and mod-jar contents don't change without a server restart,
* which restarts the mod too) — see {@link BlockRegistryEntry}/{@link BlockTexture}'s javadocs.
* {@link #sendBlockTextures} batches into multiple messages (see {@code BLOCK_TEXTURE_BATCH_SIZE})
* rather than one huge frame, since a heavily-modded server can have thousands of block textures.
*
* Phase 13: {@code block_models} is sent the same way, once, alongside {@code block_registry}/
* {@code block_textures} — see {@link BlockModelFile}'s javadoc for why the mod ships every model
* file it finds rather than trying to resolve blockstate-to-model references itself.
* {@link #sendBlockModels} batches the same way {@link #sendBlockTextures} does, for the same
* reason (blockstate/model JSON files, while individually small, can number in the thousands on
* a heavily-modded pack).
*
* This class avoids a third-party JSON/WS library entirely (see {@link SimpleWebSocketClient}
* and {@link MiniJson}'s javadoc) to keep the mod's classpath free of anything that would need
@@ -61,6 +108,9 @@ public class DefaultBackendConnection implements BackendConnection {
private String url;
private String serverToken;
private volatile ChatListener chatListener;
private volatile WaypointShareListener waypointShareListener;
private volatile Runnable readyListener;
public DefaultBackendConnection(Consumer<String> logInfo, Consumer<String> logWarn) {
this.logInfo = logInfo;
@@ -144,6 +194,8 @@ public class DefaultBackendConnection implements BackendConnection {
if (ok) {
serverReady = true;
logInfo.accept("authenticated with backend as server " + obj.get("serverId"));
Runnable listener = readyListener;
if (listener != null) listener.run();
} else {
serverReady = false;
logWarn.accept("backend rejected connection: " + obj.get("error"));
@@ -151,6 +203,23 @@ public class DefaultBackendConnection implements BackendConnection {
}
} else if ("error".equals(type)) {
logWarn.accept("backend reported error: " + obj.get("error"));
} else if ("chat".equals(type)) {
ChatListener listener = chatListener;
if (listener != null) {
listener.onChatMessage(String.valueOf(obj.get("username")), String.valueOf(obj.get("message")));
}
} else if ("waypoint_share".equals(type)) {
WaypointShareListener listener = waypointShareListener;
if (listener != null) {
listener.onWaypointShare(new WaypointShare(
String.valueOf(obj.get("name")),
((Number) obj.get("x")).intValue(),
((Number) obj.get("y")).intValue(),
((Number) obj.get("z")).intValue(),
String.valueOf(((Number) obj.get("dimension")).intValue()),
String.valueOf(obj.get("color")),
"xaero".equals(obj.get("format")) ? WaypointShare.Format.XAERO : WaypointShare.Format.JOURNEYMAP));
}
}
}
@@ -214,8 +283,119 @@ public class DefaultBackendConnection implements BackendConnection {
@Override
public void sendLinkRequest(LinkRequest request) {
// The `/mcmapper link` flow (Phase 3) isn't wired up yet — nothing calls this in Phase 1.
logWarn.accept("sendLinkRequest called before Phase 3's link flow is implemented — ignoring");
if (!serverReady) return;
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "link_request");
msg.put("code", request.code);
msg.put("uuid", request.uuid);
msg.put("username", request.username);
msg.put("authMode", request.authMode == LinkRequest.AuthMode.ONLINE ? "online" : "offline");
sendRaw("link_request", msg);
}
@Override
public void sendChatMessage(String uuid, String username, String message) {
if (!serverReady) return;
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "chat");
msg.put("uuid", uuid);
msg.put("username", username);
msg.put("message", message);
sendRaw("chat", msg);
}
@Override
public void sendPlayerPositions(int dimension, List<PlayerPosition> players) {
if (!serverReady) return;
List<Object> playerList = new ArrayList<>();
for (PlayerPosition p : players) {
Map<String, Object> obj = new LinkedHashMap<>();
obj.put("uuid", p.uuid);
obj.put("username", p.username);
obj.put("x", (double) p.x);
obj.put("y", (double) p.y);
obj.put("z", (double) p.z);
playerList.add(obj);
}
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "player_positions");
msg.put("dimension", (double) dimension);
msg.put("players", playerList);
sendRaw("player_positions", msg);
}
private static final int BLOCK_TEXTURE_BATCH_SIZE = 50;
@Override
public void sendBlockRegistry(List<BlockRegistryEntry> entries) {
if (entries.isEmpty() || !serverReady) return;
List<Object> entryList = new ArrayList<>();
for (BlockRegistryEntry e : entries) {
Map<String, Object> obj = new LinkedHashMap<>();
obj.put("id", (double) e.id);
obj.put("name", e.name);
entryList.add(obj);
}
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "block_registry");
msg.put("entries", entryList);
sendRaw("block_registry", msg);
}
@Override
public void sendBlockTextures(List<BlockTexture> textures) {
if (textures.isEmpty() || !serverReady) return;
for (int start = 0; start < textures.size(); start += BLOCK_TEXTURE_BATCH_SIZE) {
int end = Math.min(start + BLOCK_TEXTURE_BATCH_SIZE, textures.size());
List<Object> textureList = new ArrayList<>();
for (BlockTexture t : textures.subList(start, end)) {
Map<String, Object> obj = new LinkedHashMap<>();
obj.put("name", t.name);
obj.put("dataBase64", Base64.getEncoder().encodeToString(t.pngBytes));
textureList.add(obj);
}
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "block_textures");
msg.put("textures", textureList);
sendRaw("block_textures", msg);
}
}
private static final int BLOCK_MODEL_BATCH_SIZE = 50;
@Override
public void sendBlockModels(List<BlockModelFile> files) {
if (files.isEmpty() || !serverReady) return;
for (int start = 0; start < files.size(); start += BLOCK_MODEL_BATCH_SIZE) {
int end = Math.min(start + BLOCK_MODEL_BATCH_SIZE, files.size());
List<Object> entryList = new ArrayList<>();
for (BlockModelFile f : files.subList(start, end)) {
Map<String, Object> obj = new LinkedHashMap<>();
obj.put("kind", f.kind);
obj.put("name", f.name);
obj.put("json", f.json);
entryList.add(obj);
}
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "block_models");
msg.put("entries", entryList);
sendRaw("block_models", msg);
}
}
@Override
public void setReadyListener(Runnable onReady) {
this.readyListener = onReady;
}
@Override
public void setChatListener(ChatListener listener) {
this.chatListener = listener;
}
@Override
public void setWaypointShareListener(WaypointShareListener listener) {
this.waypointShareListener = listener;
}
private void sendRaw(String label, Map<String, Object> message) {
@@ -0,0 +1,28 @@
package com.octoturge.mcmapper.common;
import java.security.SecureRandom;
/**
* Generates the short code a player reads off their screen and types into the web link page —
* see {@code /mcmapper link} on the mod side and {@code link.ts}'s storeLinkCode/redeemLinkCode
* on the backend. The mod generates this itself (rather than asking the backend for one) so it
* can show it to the player immediately, without waiting on a network round-trip.
*/
public final class LinkCodeGenerator {
// Excludes visually-confusable characters (0/O, 1/I/L) since this gets read off a chat line
// and typed back on a keyboard/phone.
static final String CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
private static final int LENGTH = 6;
private static final SecureRandom RANDOM = new SecureRandom();
private LinkCodeGenerator() {
}
public static String generate() {
StringBuilder sb = new StringBuilder(LENGTH);
for (int i = 0; i < LENGTH; i++) {
sb.append(CHARSET.charAt(RANDOM.nextInt(CHARSET.length())));
}
return sb.toString();
}
}
@@ -0,0 +1,48 @@
package com.octoturge.mcmapper.common;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
/**
* Picks a bounded-size, rotating slice of the currently-loaded chunk set to re-read and resend
* on each periodic reconciliation sweep (see the plan's "hybrid" change-detection decision) —
* catches mutations that never fire a {@code BlockEvent} (world-gen, other mods writing blocks
* directly, {@code /fill}, piston pushes into unloaded-at-the-time chunks, etc.) without paying
* the cost of re-reading every loaded chunk on every sweep. Pure/loader-agnostic on purpose —
* enumerating "currently loaded chunks" stays in each leaf's {@link ChunkAdapter}.
*/
public class ReconciliationScheduler {
private int cursor = 0;
/**
* @param loadedChunkKeys all currently-loaded chunks, packed as {@code (chunkX << 32) | (chunkZ & 0xFFFFFFFFL)}
* (same packing MCMapperMod already uses for its own dirty-chunk sets)
* @param maxPerSweep upper bound on how many chunks to return this call
* @return up to {@code maxPerSweep} keys to reconcile this sweep; the internal cursor
* advances so the next call continues where this one left off, wrapping around once
* every key has been covered. Not a strict guarantee if the loaded set changes
* between calls (chunks unloading/loading) — best-effort coverage is the point, a
* missed chunk just gets picked up on a later sweep or the next event-driven change.
*/
public List<Long> next(Collection<Long> loadedChunkKeys, int maxPerSweep) {
if (loadedChunkKeys.isEmpty() || maxPerSweep <= 0) {
return Collections.emptyList();
}
List<Long> sorted = new ArrayList<>(loadedChunkKeys);
Collections.sort(sorted);
int size = sorted.size();
// A batch never wraps mid-call — it stops at the end of the current cycle instead of
// splicing the start back in, so no key is ever reconciled twice before every other key
// has had a turn. This means a batch can come back smaller than maxPerSweep right at a
// cycle boundary; the next call resumes at index 0 for a full-size batch again.
int start = cursor % size;
int count = Math.min(maxPerSweep, size - start);
List<Long> batch = new ArrayList<>(sorted.subList(start, start + count));
cursor = (start + count) % size;
return batch;
}
}
@@ -12,4 +12,6 @@ public class MapperConfig {
public boolean playerTrackingEnabled = true;
public int deltaFlushIntervalTicks = 20;
public int reconciliationIntervalTicks = 20 * 60 * 5;
public int reconciliationChunksPerSweep = 50;
public int playerPositionIntervalTicks = 20 * 2;
}
@@ -0,0 +1,26 @@
package com.octoturge.mcmapper.common.protocol;
/**
* One raw blockstate or model JSON file, extracted by a leaf off its own classloader (Phase 13) —
* same classloader-is-already-there rationale as {@link BlockTexture}. {@code kind} is either
* {@code "blockstate"} (a {@code assets/<modid>/blockstates/<path>.json} file, keyed by the
* block's own registry name) or {@code "model"} (a {@code assets/<modid>/models/block/<path>.json}
* file, keyed by {@code "<modid>:block/<path>"} — the same reference shape a blockstate's
* {@code "model"} field or another model's {@code "parent"} field uses to point at it). The mod
* ships every model file it finds under a mod's {@code models/block/} tree rather than trying to
* pick out just the ones a given blockstate needs — a blockstate can reference a model at a path
* that doesn't match the block's own registry path 1:1 (e.g. a shared base model reused by several
* blocks), and the mod has no JSON parser to work that out itself; that resolution is the
* backend's job (see MCMapper-Backend's {@code worker/src/models.rs}).
*/
public final class BlockModelFile {
public final String kind;
public final String name;
public final String json;
public BlockModelFile(String kind, String name, String json) {
this.kind = kind;
this.name = name;
this.json = json;
}
}
@@ -0,0 +1,19 @@
package com.octoturge.mcmapper.common.protocol;
/**
* One entry of a leaf's numeric-blockId -&gt; registry-name dump (Phase 11) — e.g.
* {@code (4000, "botania:manapool")}. Sent once per connection via {@code
* BackendConnection#sendBlockRegistry}, since a world's id assignments (and its mod list) are
* stable for the server's lifetime. Lets the backend eventually resolve the numeric
* {@code blockId}/{@code blockMeta} already carried by {@link DeltaEvent}/{@link SectionData}
* into a texture, for blocks {@link BlockTexture} shipped a texture for.
*/
public final class BlockRegistryEntry {
public final int id;
public final String name;
public BlockRegistryEntry(int id, String name) {
this.id = id;
this.name = name;
}
}
@@ -0,0 +1,20 @@
package com.octoturge.mcmapper.common.protocol;
/**
* One block's texture, extracted by a leaf off its own classloader (Phase 11) — Forge doesn't
* split client/server jars, so every loaded mod's {@code assets/<modid>/textures/...} is already
* sitting on a dedicated server's own classpath, just unused server-side. {@code name} is the
* block's registry name (e.g. {@code "botania:manapool"}, matching {@link
* BlockRegistryEntry#name}) — a best-effort convention match against the block's own path
* segment, not a real blockstate/model JSON resolution (that's Phase 12's job), so not every
* block gets one: a leaf skips extraction rather than guessing when no file matches.
*/
public final class BlockTexture {
public final String name;
public final byte[] pngBytes;
public BlockTexture(String name, byte[] pngBytes) {
this.name = name;
this.pngBytes = pngBytes;
}
}
@@ -0,0 +1,23 @@
package com.octoturge.mcmapper.common.protocol;
/**
* One online player's throttled position (Phase 7b), block-granularity. Sent as the full current
* roster on every flush (not a diff) — same "current state, not a diff" philosophy as
* {@link DeltaEvent}'s columns — so a player logging out simply stops appearing in the next
* roster rather than needing a separate leave message.
*/
public class PlayerPosition {
public final String uuid;
public final String username;
public final int x;
public final int y;
public final int z;
public PlayerPosition(String uuid, String username, int x, int y, int z) {
this.uuid = uuid;
this.username = username;
this.x = x;
this.y = y;
this.z = z;
}
}
@@ -0,0 +1,94 @@
package com.octoturge.mcmapper.common.protocol;
/**
* Builds a plain-text chat message that a player's own client-side map mod auto-detects and
* offers to import as a waypoint — neither format needs a click-event text component, just the
* right string shape in the message body (see the two format-specific methods for sources).
* Pure string logic, loader-independent, so it lives in {@code common} and is unit-tested
* directly (see common/run-tests.sh) rather than only exercised through a real Forge chat event.
*/
public final class WaypointChatFormatter {
private WaypointChatFormatter() {
}
public static String format(WaypointShare share) {
return share.format == WaypointShare.Format.XAERO ? xaeroText(share) : journeyMapText(share);
}
/**
* JourneyMap's own chat-waypoint auto-detect syntax: a message containing
* {@code [x:..,y:..,z:..,dim:..,name:..,color:..,delete:..]} is recognized and offered as an
* importable waypoint. Source: 1whohears/JourneyMapQOL_1.7.10 (MIT), corroborated by
* TeamJM/journeymap issue #465 ("Waypoint Chat option") — both public, no JourneyMap source
* copied, just the documented text convention.
*/
public static String journeyMapText(WaypointShare share) {
return "[x:" + share.x + ",y:" + share.y + ",z:" + share.z + ",dim:" + share.dimension
+ ",name:" + share.name + ",color:" + share.color + ",delete:false]";
}
private static final int XAERO_NAME_MAX = 32;
private static final int XAERO_MARKER_MAX = 2;
/**
* Xaero's Minimap intercepts chat messages starting with {@code xaero-waypoint:} and offers
* to add the encoded waypoint. Xaero's Minimap/Worldmap are closed-source, so this schema is
* *not* from Xaero's own docs (none exist) — it's community-reverse-engineered, per the gist
* "my rough interpretation on xaero-waypoint schema" by macimas
* (gist.github.com/macimas/937a392be075b1bce7a2ae69ea933ef5): nine colon-separated fields,
* {@code name:marker:x:y:z:color:use_yaw:yaw:dimension}, where {@code color} is 0-15
* (Minecraft's chat color codes in decimal) and {@code dimension} is empty (defaults to the
* player's current dimension) or {@code Internal-<overworld|the-nether|the-end>-waypoints}.
* Flagged as best-effort: even community sources disagree on field count for this
* undocumented format, so treat this as a starting point to verify against a real Xaero
* install rather than a guaranteed-correct implementation.
*/
public static String xaeroText(WaypointShare share) {
String name = share.name.length() > XAERO_NAME_MAX ? share.name.substring(0, XAERO_NAME_MAX) : share.name;
String marker = name.length() > XAERO_MARKER_MAX ? name.substring(0, XAERO_MARKER_MAX) : name;
marker = marker.toUpperCase();
return "xaero-waypoint:" + name + ":" + marker + ":" + share.x + ":" + share.y + ":" + share.z
+ ":" + nearestChatColorCode(share.color) + ":false:0:" + xaeroDimensionSet(share.dimension);
}
private static String xaeroDimensionSet(String dimension) {
switch (dimension) {
case "0":
return "Internal-overworld-waypoints";
case "-1":
return "Internal-the-nether-waypoints";
case "1":
return "Internal-the-end-waypoints";
default:
// Anything else (a modded dimension id) isn't safely mappable to one of Xaero's
// three built-in waypoint sets — leave it empty so Xaero falls back to whichever
// dimension the receiving player is currently in.
return "";
}
}
// The 16 standard Minecraft chat/formatting colors, index == the decimal code Xaero expects.
private static final int[] CHAT_COLORS = {
0x000000, 0x0000AA, 0x00AA00, 0x00AAAA, 0xAA0000, 0xAA00AA, 0xFFAA00, 0xAAAAAA,
0x555555, 0x5555FF, 0x55FF55, 0x55FFFF, 0xFF5555, 0xFF55FF, 0xFFFF55, 0xFFFFFF,
};
/** Nearest of the 16 Minecraft chat colors to an arbitrary "#RRGGBB" hex string, by squared RGB distance. */
public static int nearestChatColorCode(String hex) {
int rgb = Integer.parseInt(hex.replace("#", ""), 16);
int r = (rgb >> 16) & 0xFF, g = (rgb >> 8) & 0xFF, b = rgb & 0xFF;
int bestCode = 0;
long bestDist = Long.MAX_VALUE;
for (int i = 0; i < CHAT_COLORS.length; i++) {
int cr = (CHAT_COLORS[i] >> 16) & 0xFF, cg = (CHAT_COLORS[i] >> 8) & 0xFF, cb = CHAT_COLORS[i] & 0xFF;
long dist = (long) (r - cr) * (r - cr) + (long) (g - cg) * (g - cg) + (long) (b - cb) * (b - cb);
if (dist < bestDist) {
bestDist = dist;
bestCode = i;
}
}
return bestCode;
}
}
@@ -0,0 +1,64 @@
package com.octoturge.mcmapper.common;
import java.util.HashSet;
import java.util.Set;
public class LinkCodeGeneratorTest {
private static int passed = 0;
private static int failed = 0;
public static void main(String[] args) {
test("generate() returns a 6-character code", LinkCodeGeneratorTest::isSixCharacters);
test("generate() only uses the unambiguous charset", LinkCodeGeneratorTest::usesExpectedCharset);
test("generate() doesn't repeat across many calls", LinkCodeGeneratorTest::isNotObviouslyRepeating);
System.out.println();
System.out.println(passed + " passed, " + failed + " failed");
if (failed > 0) {
System.exit(1);
}
}
private static void isSixCharacters() {
String code = LinkCodeGenerator.generate();
assertEquals(6, code.length());
}
private static void usesExpectedCharset() {
String code = LinkCodeGenerator.generate();
for (char c : code.toCharArray()) {
if (LinkCodeGenerator.CHARSET.indexOf(c) < 0) {
throw new AssertionError("code '" + code + "' contains char '" + c + "' outside the expected charset");
}
}
}
private static void isNotObviouslyRepeating() {
Set<String> seen = new HashSet<>();
for (int i = 0; i < 200; i++) {
seen.add(LinkCodeGenerator.generate());
}
// Not a proof of uniqueness (codes aren't guaranteed collision-free), but 200 calls
// landing on fewer than ~195 distinct values would indicate a broken/degenerate RNG.
if (seen.size() < 195) {
throw new AssertionError("expected close to 200 distinct codes from 200 calls, got " + seen.size());
}
}
private static void test(String name, Runnable body) {
try {
body.run();
passed++;
System.out.println("PASS " + name);
} catch (AssertionError e) {
failed++;
System.out.println("FAIL " + name + "" + e.getMessage());
}
}
private static void assertEquals(Object expected, Object actual) {
if (expected == null ? actual != null : !expected.equals(actual)) {
throw new AssertionError("expected <" + expected + "> but got <" + actual + ">");
}
}
}
@@ -0,0 +1,123 @@
package com.octoturge.mcmapper.common;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public class ReconciliationSchedulerTest {
private static int passed = 0;
private static int failed = 0;
public static void main(String[] args) {
test("empty loaded set returns an empty batch", ReconciliationSchedulerTest::emptySetReturnsEmpty);
test("maxPerSweep of zero (or negative) returns an empty batch", ReconciliationSchedulerTest::zeroMaxReturnsEmpty);
test("a batch is never larger than maxPerSweep", ReconciliationSchedulerTest::batchNeverExceedsMax);
test("maxPerSweep >= set size returns every key exactly once", ReconciliationSchedulerTest::coversWholeSetInOneCallWhenItFits);
test("repeated calls eventually cover every key exactly once per cycle, then wrap", ReconciliationSchedulerTest::rotatesThroughWholeSetWithoutRepeatsWithinACycle);
test("the cursor wraps around the end of the set back to the start", ReconciliationSchedulerTest::wrapsAroundTheEnd);
test("a shrinking loaded set doesn't throw or return stale keys", ReconciliationSchedulerTest::toleratesShrinkingSet);
System.out.println();
System.out.println(passed + " passed, " + failed + " failed");
if (failed > 0) {
System.exit(1);
}
}
private static void emptySetReturnsEmpty() {
ReconciliationScheduler scheduler = new ReconciliationScheduler();
List<Long> batch = scheduler.next(new ArrayList<>(), 5);
assertEquals(0, batch.size());
}
private static void zeroMaxReturnsEmpty() {
ReconciliationScheduler scheduler = new ReconciliationScheduler();
Set<Long> loaded = new LinkedHashSet<>(Arrays.asList(1L, 2L, 3L));
assertEquals(0, scheduler.next(loaded, 0).size());
assertEquals(0, scheduler.next(loaded, -1).size());
}
private static void batchNeverExceedsMax() {
ReconciliationScheduler scheduler = new ReconciliationScheduler();
Set<Long> loaded = new LinkedHashSet<>();
for (long i = 0; i < 50; i++) loaded.add(i);
for (int i = 0; i < 10; i++) {
List<Long> batch = scheduler.next(loaded, 7);
if (batch.size() > 7) {
throw new AssertionError("batch size " + batch.size() + " exceeds maxPerSweep 7");
}
}
}
private static void coversWholeSetInOneCallWhenItFits() {
ReconciliationScheduler scheduler = new ReconciliationScheduler();
Set<Long> loaded = new LinkedHashSet<>(Arrays.asList(10L, 20L, 30L));
List<Long> batch = scheduler.next(loaded, 100);
assertEquals(3, batch.size());
assertEquals(new LinkedHashSet<>(Arrays.asList(10L, 20L, 30L)), new LinkedHashSet<>(batch));
}
private static void rotatesThroughWholeSetWithoutRepeatsWithinACycle() {
ReconciliationScheduler scheduler = new ReconciliationScheduler();
Set<Long> loaded = new LinkedHashSet<>();
for (long i = 0; i < 10; i++) loaded.add(i);
Set<Long> seenThisCycle = new LinkedHashSet<>();
// 10 keys, batches of 3 -> 4 calls (3+3+3+1) to complete exactly one cycle.
for (int call = 0; call < 4; call++) {
List<Long> batch = scheduler.next(loaded, 3);
for (Long key : batch) {
if (!seenThisCycle.add(key)) {
throw new AssertionError("key " + key + " reconciled twice within a single cycle");
}
}
}
assertEquals(10, seenThisCycle.size());
}
private static void wrapsAroundTheEnd() {
ReconciliationScheduler scheduler = new ReconciliationScheduler();
Set<Long> loaded = new LinkedHashSet<>(Arrays.asList(1L, 2L, 3L, 4L, 5L));
scheduler.next(loaded, 5); // consumes the whole set in one call — cursor wraps to 0
List<Long> batch = scheduler.next(loaded, 2); // should start a fresh cycle from the top
assertEquals(2, batch.size());
if (!batch.contains(1L) || !batch.contains(2L)) {
throw new AssertionError("expected the cursor to wrap back to the start of the set, got " + batch);
}
}
private static void toleratesShrinkingSet() {
ReconciliationScheduler scheduler = new ReconciliationScheduler();
Set<Long> loaded = new LinkedHashSet<>();
for (long i = 0; i < 20; i++) loaded.add(i);
scheduler.next(loaded, 15);
Set<Long> shrunk = new LinkedHashSet<>(Arrays.asList(0L, 1L, 2L));
List<Long> batch = scheduler.next(shrunk, 15); // must not throw despite cursor now out of range
assertEquals(3, batch.size());
for (Long key : batch) {
if (!shrunk.contains(key)) {
throw new AssertionError("batch contained a key no longer in the loaded set: " + key);
}
}
}
private static void test(String name, Runnable body) {
try {
body.run();
passed++;
System.out.println("PASS " + name);
} catch (AssertionError e) {
failed++;
System.out.println("FAIL " + name + "" + e.getMessage());
}
}
private static void assertEquals(Object expected, Object actual) {
if (expected == null ? actual != null : !expected.equals(actual)) {
throw new AssertionError("expected <" + expected + "> but got <" + actual + ">");
}
}
}
@@ -0,0 +1,142 @@
package com.octoturge.mcmapper.common.json;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Hand-rolled test runner (no JUnit) for {@link MiniJson}, following the same
* "common/ must compile and run standalone under plain javac" constraint as the class under
* test — see common/README.md for why, and common/run-tests.sh to actually run this.
*/
public class MiniJsonTest {
private static int passed = 0;
private static int failed = 0;
public static void main(String[] args) {
test("writeObject encodes strings, numbers, booleans, and null", MiniJsonTest::writesScalars);
test("writeObject escapes special characters in strings", MiniJsonTest::escapesStrings);
test("writeObject encodes nested objects and arrays", MiniJsonTest::writesNestedStructures);
test("parse reads a hello_ack-shaped object", MiniJsonTest::parsesHelloAck);
test("parse reads nested arrays of objects (a columns message)", MiniJsonTest::parsesColumnsMessage);
test("parse unescapes strings", MiniJsonTest::parseUnescapesStrings);
test("parse reads numbers as Double, including negatives", MiniJsonTest::parsesNumbers);
test("write-then-parse round-trips a columns-shaped message", MiniJsonTest::roundTripsColumnsMessage);
System.out.println();
System.out.println(passed + " passed, " + failed + " failed");
if (failed > 0) {
System.exit(1);
}
}
private static void writesScalars() {
Map<String, Object> obj = new LinkedHashMap<>();
obj.put("type", "hello");
obj.put("ok", true);
obj.put("count", 3.0);
obj.put("missing", null);
assertEquals("{\"type\":\"hello\",\"ok\":true,\"count\":3.0,\"missing\":null}", MiniJson.writeObject(obj));
}
private static void escapesStrings() {
Map<String, Object> obj = new LinkedHashMap<>();
obj.put("text", "line1\nline2\t\"quoted\"\\backslash");
String json = MiniJson.writeObject(obj);
assertEquals("{\"text\":\"line1\\nline2\\t\\\"quoted\\\"\\\\backslash\"}", json);
}
private static void writesNestedStructures() {
Map<String, Object> column = new LinkedHashMap<>();
column.put("x", 1.0);
column.put("z", 2.0);
List<Object> columns = new ArrayList<>();
columns.add(column);
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "columns");
msg.put("columns", columns);
assertEquals("{\"type\":\"columns\",\"columns\":[{\"x\":1.0,\"z\":2.0}]}", MiniJson.writeObject(msg));
}
@SuppressWarnings("unchecked")
private static void parsesHelloAck() {
Object parsed = MiniJson.parse("{\"type\":\"hello_ack\",\"ok\":true,\"serverId\":\"abc-123\"}");
Map<String, Object> obj = (Map<String, Object>) parsed;
assertEquals("hello_ack", obj.get("type"));
assertEquals(Boolean.TRUE, obj.get("ok"));
assertEquals("abc-123", obj.get("serverId"));
}
@SuppressWarnings("unchecked")
private static void parsesColumnsMessage() {
String json = "{\"type\":\"columns\",\"dimension\":0,\"columns\":["
+ "{\"x\":1,\"z\":2,\"height\":64,\"blockId\":2,\"blockMeta\":0},"
+ "{\"x\":3,\"z\":4,\"height\":65,\"blockId\":3,\"blockMeta\":0}]}";
Map<String, Object> obj = (Map<String, Object>) MiniJson.parse(json);
List<Object> columns = (List<Object>) obj.get("columns");
assertEquals(2, columns.size());
Map<String, Object> first = (Map<String, Object>) columns.get(0);
assertEquals(1.0, first.get("x"));
assertEquals(64.0, first.get("height"));
}
@SuppressWarnings("unchecked")
private static void parseUnescapesStrings() {
Map<String, Object> obj = (Map<String, Object>) MiniJson.parse("{\"error\":\"bad \\\"token\\\"\\nvalue\"}");
assertEquals("bad \"token\"\nvalue", obj.get("error"));
}
@SuppressWarnings("unchecked")
private static void parsesNumbers() {
Map<String, Object> obj = (Map<String, Object>) MiniJson.parse("{\"a\":-5,\"b\":3.5,\"c\":0}");
assertEquals(-5.0, obj.get("a"));
assertEquals(3.5, obj.get("b"));
assertEquals(0.0, obj.get("c"));
}
@SuppressWarnings("unchecked")
private static void roundTripsColumnsMessage() {
// Mirrors exactly what DefaultBackendConnection.sendDeltas() builds — the real
// regression risk isn't MiniJson in isolation, it's this shape drifting silently.
Map<String, Object> col = new LinkedHashMap<>();
col.put("x", 7.0);
col.put("z", -3.0);
col.put("height", 70.0);
col.put("blockId", 2.0);
col.put("blockMeta", 0.0);
List<Object> columns = new ArrayList<>();
columns.add(col);
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "columns");
msg.put("dimension", 0.0);
msg.put("columns", columns);
String json = MiniJson.writeObject(msg);
Map<String, Object> reparsed = (Map<String, Object>) MiniJson.parse(json);
assertEquals("columns", reparsed.get("type"));
assertEquals(0.0, reparsed.get("dimension"));
List<Object> reparsedColumns = (List<Object>) reparsed.get("columns");
Map<String, Object> reparsedCol = (Map<String, Object>) reparsedColumns.get(0);
assertEquals(7.0, reparsedCol.get("x"));
assertEquals(-3.0, reparsedCol.get("z"));
assertEquals(2.0, reparsedCol.get("blockId"));
}
private static void test(String name, Runnable body) {
try {
body.run();
passed++;
System.out.println("PASS " + name);
} catch (AssertionError e) {
failed++;
System.out.println("FAIL " + name + "" + e.getMessage());
}
}
private static void assertEquals(Object expected, Object actual) {
if (expected == null ? actual != null : !expected.equals(actual)) {
throw new AssertionError("expected <" + expected + "> but got <" + actual + ">");
}
}
}
@@ -0,0 +1,101 @@
package com.octoturge.mcmapper.common.protocol;
import java.util.ArrayList;
import java.util.List;
/** Hand-rolled test runner (no JUnit) — see common/run-tests.sh. */
public class WaypointChatFormatterTest {
private static final List<String> failures = new ArrayList<>();
private static int passed = 0;
public static void main(String[] args) {
journeyMapProducesTheDocumentedBracketSyntax();
journeyMapHandlesNegativeCoordinates();
xaeroProducesTheDocumentedColonSyntaxForOverworld();
xaeroMapsKnownDimensionsToInternalWaypointSets();
xaeroDefaultsUnknownDimensionsToEmpty();
xaeroTruncatesLongNamesAndDerivesAnUppercaseMarker();
xaeroPicksTheNearestOfTheSixteenChatColorCodes();
formatDispatchesOnTheShareFormat();
System.out.println();
System.out.println((passed) + " passed, " + failures.size() + " failed");
if (!failures.isEmpty()) System.exit(1);
}
private static void journeyMapProducesTheDocumentedBracketSyntax() {
WaypointShare share = new WaypointShare("Base", 105, 72, -723, "0", "#B311CF", WaypointShare.Format.JOURNEYMAP);
check(
"journeyMap produces the documented bracket syntax",
WaypointChatFormatter.journeyMapText(share),
"[x:105,y:72,z:-723,dim:0,name:Base,color:#B311CF,delete:false]");
}
private static void journeyMapHandlesNegativeCoordinates() {
WaypointShare share = new WaypointShare("Deep", -10, 5, -20, "-1", "#FFFFFF", WaypointShare.Format.JOURNEYMAP);
check(
"journeyMap handles negative x/y/z",
WaypointChatFormatter.journeyMapText(share),
"[x:-10,y:5,z:-20,dim:-1,name:Deep,color:#FFFFFF,delete:false]");
}
private static void xaeroProducesTheDocumentedColonSyntaxForOverworld() {
WaypointShare share = new WaypointShare("Base", 105, 72, -723, "0", "#FF5555", WaypointShare.Format.XAERO);
check(
"xaero produces the documented colon syntax for the overworld",
WaypointChatFormatter.xaeroText(share),
"xaero-waypoint:Base:BA:105:72:-723:12:false:0:Internal-overworld-waypoints");
}
private static void xaeroMapsKnownDimensionsToInternalWaypointSets() {
WaypointShare nether = new WaypointShare("N", 1, 2, 3, "-1", "#FFFFFF", WaypointShare.Format.XAERO);
check("xaero maps dimension -1 to the nether waypoint set",
WaypointChatFormatter.xaeroText(nether).endsWith(":Internal-the-nether-waypoints"), true);
WaypointShare end = new WaypointShare("E", 1, 2, 3, "1", "#FFFFFF", WaypointShare.Format.XAERO);
check("xaero maps dimension 1 to the end waypoint set",
WaypointChatFormatter.xaeroText(end).endsWith(":Internal-the-end-waypoints"), true);
}
private static void xaeroDefaultsUnknownDimensionsToEmpty() {
WaypointShare modded = new WaypointShare("M", 1, 2, 3, "42", "#FFFFFF", WaypointShare.Format.XAERO);
check("xaero leaves the dimension field empty for an unrecognized dimension id (defaults to the player's current dimension)",
WaypointChatFormatter.xaeroText(modded).endsWith(":"), true);
}
private static void xaeroTruncatesLongNamesAndDerivesAnUppercaseMarker() {
String longName = "ThisWaypointNameIsDefinitelyLongerThanThirtyTwoCharacters";
WaypointShare share = new WaypointShare(longName, 0, 0, 0, "0", "#FFFFFF", WaypointShare.Format.XAERO);
String text = WaypointChatFormatter.xaeroText(share);
String[] fields = text.split(":");
check("xaero truncates the name to 32 characters", fields[1].length() <= 32, true);
check("xaero derives a 2-character uppercase marker from the name", fields[2], "TH");
}
private static void xaeroPicksTheNearestOfTheSixteenChatColorCodes() {
check("pure red maps to chat color code 12 (red)", WaypointChatFormatter.nearestChatColorCode("#FF5555"), 12);
check("pure white maps to chat color code 15 (white)", WaypointChatFormatter.nearestChatColorCode("#FFFFFF"), 15);
check("pure black maps to chat color code 0 (black)", WaypointChatFormatter.nearestChatColorCode("#000000"), 0);
}
private static void formatDispatchesOnTheShareFormat() {
WaypointShare jm = new WaypointShare("A", 1, 2, 3, "0", "#FFFFFF", WaypointShare.Format.JOURNEYMAP);
check("format() dispatches to journeyMapText for JOURNEYMAP",
WaypointChatFormatter.format(jm).startsWith("["), true);
WaypointShare xaero = new WaypointShare("A", 1, 2, 3, "0", "#FFFFFF", WaypointShare.Format.XAERO);
check("format() dispatches to xaeroText for XAERO",
WaypointChatFormatter.format(xaero).startsWith("xaero-waypoint:"), true);
}
private static void check(String name, Object actual, Object expected) {
boolean ok = actual == null ? expected == null : actual.equals(expected);
if (ok) {
passed++;
System.out.println("PASS " + name);
} else {
failures.add(name);
System.out.println("FAIL " + name + " -- expected <" + expected + "> but got <" + actual + ">");
}
}
}
@@ -0,0 +1,209 @@
package com.octoturge.mcmapper.forge1122;
import com.octoturge.mcmapper.common.protocol.BlockModelFile;
import com.octoturge.mcmapper.common.protocol.BlockRegistryEntry;
import com.octoturge.mcmapper.common.protocol.BlockTexture;
import net.minecraft.block.Block;
import net.minecraft.util.ResourceLocation;
import net.minecraftforge.fml.common.Loader;
import net.minecraftforge.fml.common.ModContainer;
import org.apache.logging.log4j.Logger;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* Phase 11: best-effort classloader extraction of block textures + the numeric-id -&gt; registry-
* name mapping needed to make sense of them on the backend (see {@link BlockRegistryEntry}/
* {@link BlockTexture}'s javadocs). Forge doesn't split client/server jars, so every loaded mod's
* texture assets are already sitting on this dedicated server's own classpath — {@code
* Block.REGISTRY} gives every registered block's numeric id + registry name (same {@code
* Block.getIdFromBlock} used for the existing column/section wire encoding, see
* Forge1122ChunkAdapter), and the JVM's own merged mod classloader can load
* {@code assets/<modid>/textures/blocks/<path>.png} directly with no jar-file bookkeeping.
*
* This is a *convention* match (the registry name's path segment as the texture filename), not a
* real blockstate/model JSON resolution — {@link #extractModels} (Phase 13) is what feeds that,
* on the backend side (see MCMapper-Backend's {@code worker/src/models.rs}). A block whose texture
* doesn't match this convention (most non-cube blocks) simply isn't found by {@link
* #extractTextures} and is skipped there — no worse than pre-Phase-11 behavior for that block.
*/
final class BlockAssetExtractor {
private BlockAssetExtractor() {
}
static List<BlockRegistryEntry> extractRegistry() {
List<BlockRegistryEntry> entries = new ArrayList<>();
for (Block block : Block.REGISTRY) {
ResourceLocation name = block.getRegistryName();
if (name == null) continue;
entries.add(new BlockRegistryEntry(Block.getIdFromBlock(block), name.toString()));
}
return entries;
}
static List<BlockTexture> extractTextures(Logger logger) {
ClassLoader classLoader = BlockAssetExtractor.class.getClassLoader();
List<BlockTexture> textures = new ArrayList<>();
int attempted = 0;
for (Block block : Block.REGISTRY) {
ResourceLocation name = block.getRegistryName();
if (name == null) continue;
attempted++;
// ResourceLocation's domain/path accessor name varies by MCP mapping version (e.g.
// getResourceDomain/getResourcePath vs. getNamespace/getPath) — toString()'s
// "modid:path" shape is stable across all of them, so split on that instead.
String full = name.toString();
int colon = full.indexOf(':');
String modid = colon >= 0 ? full.substring(0, colon) : full;
String path = colon >= 0 ? full.substring(colon + 1) : full;
byte[] png = readTexture(classLoader, modid, path);
if (png != null) textures.add(new BlockTexture(full, png));
}
logger.info("MCMapper (1.12.2 leaf) extracted " + textures.size() + "/" + attempted
+ " block textures for Phase 11 texture-averaged rendering (best-effort convention match)");
return textures;
}
private static byte[] readTexture(ClassLoader classLoader, String modid, String path) {
// 1.12.2 uses textures/blocks/ (plural) — see the backend's block_names.rs doc comment
// for the 1.13+ textures/block/ rename, tried second in case a mod ships modern-style paths.
byte[] png = readResource(classLoader, "assets/" + modid + "/textures/blocks/" + path + ".png");
if (png != null) return png;
return readResource(classLoader, "assets/" + modid + "/textures/block/" + path + ".png");
}
private static byte[] readResource(ClassLoader classLoader, String resourcePath) {
try (InputStream in = classLoader.getResourceAsStream(resourcePath)) {
if (in == null) return null;
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
return out.toByteArray();
} catch (IOException e) {
return null;
}
}
/**
* Phase 13: ships every {@code assets/<modid>/blockstates/*.json} and {@code
* assets/<modid>/models/block/**\/*.json} file for every currently-active mod, keyed the same
* way {@code worker/src/models.rs}'s {@code ModelRegistry::build} expects — see {@link
* BlockModelFile}'s javadoc for why this ships every model file rather than trying to resolve
* which ones a blockstate actually needs. Walks each mod's source (a jar in production, a raw
* exploded directory when running from an IDE/dev environment — {@code ModContainer#getSource}
* covers both) directly rather than the classloader, since listing a directory's contents
* (unlike reading one known file by path, as {@link #extractTextures} does) isn't something
* {@code ClassLoader#getResourceAsStream} can do.
*/
static List<BlockModelFile> extractModels(Logger logger) {
List<BlockModelFile> files = new ArrayList<>();
for (ModContainer mod : Loader.instance().getActiveModList()) {
File source = mod.getSource();
if (source == null) continue;
if (source.isFile()) {
extractModelsFromJar(source, mod.getModId(), files);
} else if (source.isDirectory()) {
extractModelsFromDirectory(source, mod.getModId(), files);
}
}
logger.info("MCMapper (1.12.2 leaf) extracted " + files.size()
+ " blockstate/model JSON files for Phase 13 non-cube model rendering");
return files;
}
private static void extractModelsFromJar(File jarFile, String modid, List<BlockModelFile> out) {
try (ZipFile zip = new ZipFile(jarFile)) {
Enumeration<? extends ZipEntry> entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (entry.isDirectory()) continue;
ModelPath parsed = classify(modid, entry.getName());
if (parsed == null) continue;
byte[] json = readZipEntry(zip, entry);
if (json != null) out.add(new BlockModelFile(parsed.kind, parsed.name, new String(json, StandardCharsets.UTF_8)));
}
} catch (IOException e) {
// Best-effort, matching readTexture's convention — skip this mod's models entirely.
}
}
private static byte[] readZipEntry(ZipFile zip, ZipEntry entry) {
try (InputStream in = zip.getInputStream(entry)) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
return out.toByteArray();
} catch (IOException e) {
return null;
}
}
private static void extractModelsFromDirectory(File modRoot, String modid, List<BlockModelFile> out) {
File assetsDir = new File(modRoot, "assets/" + modid);
if (assetsDir.isDirectory()) walkDirectory(assetsDir, "assets/" + modid + "/", modid, out);
}
private static void walkDirectory(File dir, String relPrefix, String modid, List<BlockModelFile> out) {
File[] children = dir.listFiles();
if (children == null) return;
for (File child : children) {
String rel = relPrefix + child.getName();
if (child.isDirectory()) {
walkDirectory(child, rel + "/", modid, out);
continue;
}
ModelPath parsed = classify(modid, rel);
if (parsed == null) continue;
try {
byte[] json = Files.readAllBytes(child.toPath());
out.add(new BlockModelFile(parsed.kind, parsed.name, new String(json, StandardCharsets.UTF_8)));
} catch (IOException e) {
// Best-effort — skip this one file.
}
}
}
/**
* Classifies one archive/directory entry path (e.g. {@code "assets/botania/blockstates/
* manapool.json"}) into a wire-ready {@code (kind, name)} pair, or {@code null} if it's not a
* blockstate/model JSON file for this mod. Blockstates are matched flat (no subfolders, matching
* vanilla's own layout); models are matched recursively under {@code models/block/} since mods
* are free to nest them (e.g. a shared {@code models/block/base/} folder).
*/
private static ModelPath classify(String modid, String entryPath) {
String prefix = "assets/" + modid + "/";
if (!entryPath.startsWith(prefix) || !entryPath.endsWith(".json")) return null;
String rest = entryPath.substring(prefix.length());
if (rest.startsWith("blockstates/") && rest.indexOf('/', "blockstates/".length()) < 0) {
String path = rest.substring("blockstates/".length(), rest.length() - ".json".length());
return new ModelPath("blockstate", modid + ":" + path);
}
if (rest.startsWith("models/block/")) {
String path = rest.substring("models/block/".length(), rest.length() - ".json".length());
return new ModelPath("model", modid + ":block/" + path);
}
return null;
}
private static final class ModelPath {
final String kind;
final String name;
ModelPath(String kind, String name) {
this.kind = kind;
this.name = name;
}
}
}
@@ -0,0 +1,32 @@
package com.octoturge.mcmapper.forge1122;
import com.octoturge.mcmapper.common.ChatBridge;
import com.octoturge.mcmapper.common.protocol.WaypointChatFormatter;
import com.octoturge.mcmapper.common.protocol.WaypointShare;
import net.minecraft.server.MinecraftServer;
import net.minecraft.util.text.TextComponentString;
import org.apache.logging.log4j.Logger;
public class Forge1122ChatBridge implements ChatBridge {
private final MinecraftServer server;
private final Logger logger;
public Forge1122ChatBridge(MinecraftServer server, Logger logger) {
this.server = server;
this.logger = logger;
}
@Override
public void injectWebChatMessage(String displayName, String message) {
server.getPlayerList().sendMessage(new TextComponentString("[Web] " + displayName + ": " + message));
}
@Override
public void injectWaypointShare(WaypointShare waypoint) {
// Neither JourneyMap's nor Xaero's chat-waypoint syntax needs a click-event component —
// both client mods auto-detect the right plain-text shape in a normal chat message (see
// WaypointChatFormatter's javadoc for sources), so a broadcast TextComponentString is
// enough, same as injectWebChatMessage above.
server.getPlayerList().sendMessage(new TextComponentString(WaypointChatFormatter.format(waypoint)));
}
}
@@ -10,6 +10,7 @@ import net.minecraft.world.World;
import net.minecraft.world.WorldServer;
import net.minecraft.world.chunk.Chunk;
import net.minecraft.world.chunk.storage.ExtendedBlockStorage;
import net.minecraft.world.gen.ChunkProviderServer;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.common.util.BlockSnapshot;
import net.minecraftforge.event.world.BlockEvent;
@@ -101,6 +102,15 @@ public class Forge1122ChunkAdapter implements ChunkAdapter {
MinecraftForge.EVENT_BUS.register(new EventHooks(sink));
}
@Override
public List<Long> loadedChunkKeys() {
List<Long> keys = new ArrayList<>();
for (Chunk chunk : ((ChunkProviderServer) world.getChunkProvider()).getLoadedChunks()) {
keys.add((((long) chunk.x) << 32) | (chunk.z & 0xFFFFFFFFL));
}
return keys;
}
/**
* {@code BlockEvent.BreakEvent} fires *before* the block is actually removed (it's
* cancellable), so reading world state synchronously inside that handler would see the
@@ -0,0 +1,55 @@
package com.octoturge.mcmapper.forge1122;
import com.octoturge.mcmapper.common.BackendConnection;
import com.octoturge.mcmapper.common.LinkCodeGenerator;
import com.octoturge.mcmapper.common.protocol.LinkRequest;
import net.minecraft.command.CommandBase;
import net.minecraft.command.CommandException;
import net.minecraft.command.ICommandSender;
import net.minecraft.entity.player.EntityPlayerMP;
import net.minecraft.server.MinecraftServer;
import net.minecraft.util.text.TextComponentString;
/** {@code /mcmapper link} — see LinkRequest.java's javadoc and MCMapper-Backend's link.ts. */
public class LinkCommand extends CommandBase {
private final BackendConnection connection;
public LinkCommand(BackendConnection connection) {
this.connection = connection;
}
@Override
public String getName() {
return "mcmapper";
}
@Override
public String getUsage(ICommandSender sender) {
return "/mcmapper link";
}
@Override
public int getRequiredPermissionLevel() {
return 0; // any player may link their own account
}
@Override
public void execute(MinecraftServer server, ICommandSender sender, String[] args) throws CommandException {
if (args.length == 0 || !"link".equals(args[0])) {
sender.sendMessage(new TextComponentString("Usage: /mcmapper link"));
return;
}
if (!(sender instanceof EntityPlayerMP)) {
sender.sendMessage(new TextComponentString("Only players can link an account."));
return;
}
EntityPlayerMP player = (EntityPlayerMP) sender;
String code = LinkCodeGenerator.generate();
LinkRequest.AuthMode authMode =
server.isServerInOnlineMode() ? LinkRequest.AuthMode.ONLINE : LinkRequest.AuthMode.OFFLINE;
connection.sendLinkRequest(new LinkRequest(player.getUniqueID().toString(), player.getName(), code, authMode));
player.sendMessage(new TextComponentString(
"Your MCMapper link code: " + code + " — enter it on the map site within 10 minutes."));
}
}
@@ -2,12 +2,21 @@ package com.octoturge.mcmapper.forge1122;
import com.octoturge.mcmapper.common.ChunkAdapter;
import com.octoturge.mcmapper.common.DefaultBackendConnection;
import com.octoturge.mcmapper.common.ReconciliationScheduler;
import com.octoturge.mcmapper.common.config.MapperConfig;
import com.octoturge.mcmapper.common.protocol.BlockModelFile;
import com.octoturge.mcmapper.common.protocol.BlockRegistryEntry;
import com.octoturge.mcmapper.common.protocol.BlockTexture;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.PlayerPosition;
import com.octoturge.mcmapper.common.protocol.SectionData;
import net.minecraft.entity.player.EntityPlayerMP;
import net.minecraft.server.MinecraftServer;
import net.minecraft.util.math.MathHelper;
import net.minecraft.world.WorldServer;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.common.config.Configuration;
import net.minecraftforge.event.ServerChatEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.common.event.FMLPreInitializationEvent;
import net.minecraftforge.fml.common.event.FMLServerStartingEvent;
@@ -26,9 +35,13 @@ import java.util.Set;
/**
* Entry point for the 1.12.2 leaf — the primary/first-implemented target (Enigmatica 2).
* Phase 1: connects to the backend over WS, backfills the overworld's loaded chunks, and
* flushes event-driven column deltas on a timer (no periodic reconciliation sweep yet — see
* plan's Phase 7). Phase 2 adds full-section backfill/flush for 3D mesh rendering. Chat bridge
* and `/mcmapper link` land in Phase 3.
* flushes event-driven column deltas on a timer. Phase 2 adds full-section backfill/flush for
* 3D mesh rendering. Phase 3 adds `/mcmapper link` and the two-way chat bridge (see LinkCommand,
* Forge1122ChatBridge). Phase 7 adds a periodic reconciliation sweep (see {@link
* ReconciliationScheduler}) that catches non-event mutations (world-gen, other mods, `/fill`)
* the event-driven hooks in {@link Forge1122ChunkAdapter} never see. Phase 7b adds throttled
* online-player position tracking (config-gated via {@code playerTrackingEnabled}), overworld
* only — see {@link #sendPlayerPositions()}.
*/
@Mod(modid = MCMapperMod.MOD_ID, name = "MCMapper", version = MCMapperMod.VERSION)
public class MCMapperMod {
@@ -43,7 +56,11 @@ public class MCMapperMod {
private Forge1122ChunkAdapter adapter;
private final List<DeltaEvent> pendingDeltas = Collections.synchronizedList(new ArrayList<>());
private final Set<Long> pendingSectionChunks = Collections.synchronizedSet(new LinkedHashSet<>());
private final ReconciliationScheduler reconciliationScheduler = new ReconciliationScheduler();
private MinecraftServer mcServer;
private int ticksSinceFlush = 0;
private int ticksSinceReconciliation = 0;
private int ticksSincePlayerPositions = 0;
@Mod.EventHandler
public void preInit(FMLPreInitializationEvent event) {
@@ -60,6 +77,22 @@ public class MCMapperMod {
config.deltaFlushIntervalTicks = forgeConfig.getInt("deltaFlushIntervalTicks", "network",
config.deltaFlushIntervalTicks, 1, 20 * 60,
"How often (in ticks) to batch and flush block-change deltas to the backend");
config.reconciliationIntervalTicks = forgeConfig.getInt("reconciliationIntervalTicks", "network",
config.reconciliationIntervalTicks, 20, 20 * 60 * 60,
"How often (in ticks) to run a reconciliation sweep, re-reading and resending a " +
"rotating slice of loaded chunks to catch changes event hooks miss " +
"(world-gen, other mods, /fill, etc.)");
config.reconciliationChunksPerSweep = forgeConfig.getInt("reconciliationChunksPerSweep", "network",
config.reconciliationChunksPerSweep, 1, 5000,
"Max chunks to re-read and resend per reconciliation sweep");
config.playerTrackingEnabled = forgeConfig.getBoolean("playerTrackingEnabled", "network",
config.playerTrackingEnabled,
"Whether to send throttled online-player positions to the backend at all. The " +
"backend also has its own per-server admin toggle deciding whether it " +
"relays this on to web viewers — this setting only controls the mod side.");
config.playerPositionIntervalTicks = forgeConfig.getInt("playerPositionIntervalTicks", "network",
config.playerPositionIntervalTicks, 5, 20 * 60,
"How often (in ticks) to send the online-player roster to the backend, when playerTrackingEnabled");
if (forgeConfig.hasChanged()) forgeConfig.save();
}
@@ -70,7 +103,25 @@ public class MCMapperMod {
return;
}
mcServer = event.getServer();
connection = new DefaultBackendConnection(LOGGER::info, LOGGER::warn);
// Phase 11: extracted once, synchronously, here — Block.REGISTRY is already fully
// populated by server-starting time (all mod block registration happens during loading,
// well before this event fires), and it's a pure classpath read (no network), so this
// doesn't meaningfully delay startup even on a heavily-modded pack. Sent via the ready
// listener below rather than right after connect() (which is async — see
// BackendConnection#setReadyListener's javadoc for why a naive immediate send would
// silently no-op).
List<BlockRegistryEntry> blockRegistry = BlockAssetExtractor.extractRegistry();
List<BlockTexture> blockTextures = BlockAssetExtractor.extractTextures(LOGGER);
List<BlockModelFile> blockModels = BlockAssetExtractor.extractModels(LOGGER);
connection.setReadyListener(() -> {
connection.sendBlockRegistry(blockRegistry);
connection.sendBlockTextures(blockTextures);
connection.sendBlockModels(blockModels);
});
connection.connect(config.backendUrl, config.serverToken);
LOGGER.info("MCMapper (1.12.2 leaf) connecting to " + config.backendUrl);
@@ -88,17 +139,66 @@ public class MCMapperMod {
}
});
Forge1122ChatBridge chatBridge = new Forge1122ChatBridge(event.getServer(), LOGGER);
connection.setChatListener(chatBridge::injectWebChatMessage);
connection.setWaypointShareListener(chatBridge::injectWaypointShare);
event.registerServerCommand(new LinkCommand(connection));
MinecraftForge.EVENT_BUS.register(this);
}
@SubscribeEvent
public void onServerChat(ServerChatEvent event) {
EntityPlayerMP player = event.getPlayer();
connection.sendChatMessage(player.getUniqueID().toString(), player.getName(), event.getMessage());
}
@SubscribeEvent
public void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
if (++ticksSinceFlush < config.deltaFlushIntervalTicks) return;
if (++ticksSinceFlush >= config.deltaFlushIntervalTicks) {
ticksSinceFlush = 0;
flush();
}
if (++ticksSinceReconciliation >= config.reconciliationIntervalTicks) {
ticksSinceReconciliation = 0;
reconcile();
}
if (config.playerTrackingEnabled && ++ticksSincePlayerPositions >= config.playerPositionIntervalTicks) {
ticksSincePlayerPositions = 0;
sendPlayerPositions();
}
}
private void sendPlayerPositions() {
List<PlayerPosition> players = new ArrayList<>();
for (EntityPlayerMP player : mcServer.getPlayerList().getPlayers()) {
if (player.dimension != 0) continue;
players.add(new PlayerPosition(player.getUniqueID().toString(), player.getName(),
MathHelper.floor(player.posX), MathHelper.floor(player.posY), MathHelper.floor(player.posZ)));
}
connection.sendPlayerPositions(0, players);
}
private void reconcile() {
List<Long> chunkKeys = reconciliationScheduler.next(adapter.loadedChunkKeys(),
config.reconciliationChunksPerSweep);
if (chunkKeys.isEmpty()) return;
String dimensionId = adapter.getDimensionId();
for (long key : chunkKeys) {
int chunkX = (int) (key >> 32);
int chunkZ = (int) key;
List<DeltaEvent> columns = adapter.readChunk(dimensionId, chunkX, chunkZ);
if (!columns.isEmpty()) connection.sendDeltas(columns);
List<SectionData> sections = adapter.readSections(chunkX, chunkZ);
if (!sections.isEmpty()) connection.sendSections(dimensionId, chunkX, chunkZ, sections);
}
}
private void flush() {
List<DeltaEvent> batch;
synchronized (pendingDeltas) {
@@ -0,0 +1,30 @@
package com.octoturge.mcmapper.forge1710;
import com.octoturge.mcmapper.common.ChatBridge;
import com.octoturge.mcmapper.common.protocol.WaypointChatFormatter;
import com.octoturge.mcmapper.common.protocol.WaypointShare;
import net.minecraft.server.MinecraftServer;
import net.minecraft.util.ChatComponentText;
import org.apache.logging.log4j.Logger;
public class Forge1710ChatBridge implements ChatBridge {
private final MinecraftServer server;
private final Logger logger;
public Forge1710ChatBridge(MinecraftServer server, Logger logger) {
this.server = server;
this.logger = logger;
}
@Override
public void injectWebChatMessage(String displayName, String message) {
server.getConfigurationManager().sendChatMsg(new ChatComponentText("[Web] " + displayName + ": " + message));
}
@Override
public void injectWaypointShare(WaypointShare waypoint) {
// Same rationale as Forge1122ChatBridge: neither JourneyMap's nor Xaero's chat-waypoint
// syntax needs a click-event component, so a plain broadcast message is enough.
server.getConfigurationManager().sendChatMsg(new ChatComponentText(WaypointChatFormatter.format(waypoint)));
}
}
@@ -0,0 +1,177 @@
package com.octoturge.mcmapper.forge1710;
import com.octoturge.mcmapper.common.ChunkAdapter;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.SectionData;
import net.minecraft.block.Block;
import net.minecraft.world.World;
import net.minecraft.world.WorldServer;
import net.minecraft.world.chunk.Chunk;
import net.minecraft.world.chunk.storage.ExtendedBlockStorage;
import net.minecraft.world.gen.ChunkProviderServer;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.event.world.BlockEvent;
import net.minecraftforge.event.world.ChunkEvent;
import cpw.mods.fml.common.eventhandler.SubscribeEvent;
import cpw.mods.fml.common.gameevent.TickEvent;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* 1.7.10's {@link ChunkAdapter} — one Forge/MC generation older than {@code forge-1_12_2}'s (pre
* block-state: raw {@code Block} + metadata int, not {@code IBlockState}), and pre-FML-repackage
* (event/tick classes live under {@code cpw.mods.fml}, not {@code net.minecraftforge.fml}).
* Column top-of-height lookups and section iteration otherwise mirror {@code
* Forge1122ChunkAdapter} exactly — see its javadoc for the reconciliation-vs-event framing.
*/
public class Forge1710ChunkAdapter implements ChunkAdapter {
private final WorldServer world;
private final String dimensionId;
public Forge1710ChunkAdapter(WorldServer world) {
this.world = world;
this.dimensionId = String.valueOf(world.provider.dimensionId);
}
public String getDimensionId() {
return dimensionId;
}
@Override
public List<DeltaEvent> readChunk(String dimension, int chunkX, int chunkZ) {
Chunk chunk = world.getChunkFromChunkCoords(chunkX, chunkZ);
List<DeltaEvent> events = new ArrayList<DeltaEvent>(256);
long now = System.currentTimeMillis();
for (int lx = 0; lx < 16; lx++) {
for (int lz = 0; lz < 16; lz++) {
events.add(readColumn(chunk, chunkX * 16 + lx, chunkZ * 16 + lz, lx, lz, now,
DeltaEvent.Source.RECONCILIATION));
}
}
return events;
}
private DeltaEvent readColumn(Chunk chunk, int worldX, int worldZ, int localX, int localZ,
long now, DeltaEvent.Source source) {
int height = chunk.getHeightValue(localX, localZ);
int topY = Math.max(0, height - 1);
Block block = chunk.getBlock(localX, topY, localZ);
int meta = chunk.getBlockMetadata(localX, topY, localZ);
int id = Block.getIdFromBlock(block);
int blockStateId = ((id & 0xFFF) << 4) | (meta & 0xF);
return new DeltaEvent(dimensionId, worldX, topY, worldZ, blockStateId, now, source);
}
@Override
public List<SectionData> readSections(int chunkX, int chunkZ) {
Chunk chunk = world.getChunkFromChunkCoords(chunkX, chunkZ);
ExtendedBlockStorage[] storage = chunk.getBlockStorageArray();
List<SectionData> sections = new ArrayList<SectionData>();
for (int sectionY = 0; sectionY < storage.length; sectionY++) {
ExtendedBlockStorage ebs = storage[sectionY];
// Same empty-section shortcut as forge-1_12_2 — vanilla leaves storage null for an
// all-air section, and ExtendedBlockStorage tracks its own non-air count.
if (ebs == null || ebs.isEmpty()) continue;
char[] blocks = new char[4096];
for (int ly = 0; ly < 16; ly++) {
for (int lz = 0; lz < 16; lz++) {
for (int lx = 0; lx < 16; lx++) {
Block block = ebs.getBlockByExtId(lx, ly, lz);
int meta = ebs.getExtBlockMetadata(lx, ly, lz);
int id = Block.getIdFromBlock(block);
int blockStateId = ((id & 0xFFF) << 4) | (meta & 0xF);
blocks[(ly * 16 + lz) * 16 + lx] = (char) blockStateId;
}
}
}
sections.add(new SectionData(sectionY, blocks));
}
return sections;
}
@Override
public void registerEventHooks(DeltaSink sink) {
MinecraftForge.EVENT_BUS.register(new EventHooks(sink));
}
@Override
public List<Long> loadedChunkKeys() {
List<Long> keys = new ArrayList<Long>();
ChunkProviderServer provider = (ChunkProviderServer) world.getChunkProvider();
for (Object obj : provider.loadedChunks) {
Chunk chunk = (Chunk) obj;
keys.add((((long) chunk.xPosition) << 32) | (chunk.zPosition & 0xFFFFFFFFL));
}
return keys;
}
/**
* Same "mark dirty, drain once per tick" strategy as {@code Forge1122ChunkAdapter} (see its
* javadoc) — {@code BlockEvent.BreakEvent} still fires before the actual removal here. This
* era's {@code BlockEvent} carries {@code world}/{@code x}/{@code y}/{@code z} as plain public
* fields rather than a {@code World}/{@code BlockPos} getter pair, and there's no
* {@code MultiPlaceEvent} hook (multi-block placements like doors/beds are caught by the
* periodic reconciliation sweep instead — an acceptable gap given it's already a documented
* fallback for anything event hooks miss).
*/
private class EventHooks {
private final DeltaSink sink;
private final Set<Long> dirtyColumns = ConcurrentHashMap.newKeySet();
EventHooks(DeltaSink sink) {
this.sink = sink;
}
@SubscribeEvent
public void onBlockBreak(BlockEvent.BreakEvent event) {
markDirty(event.world, event.x, event.z);
}
@SubscribeEvent
public void onBlockPlace(BlockEvent.PlaceEvent event) {
markDirty(event.world, event.x, event.z);
}
@SubscribeEvent
public void onChunkLoad(ChunkEvent.Load event) {
if (event.world != world) return;
Chunk chunk = event.getChunk();
for (DeltaEvent e : readChunk(dimensionId, chunk.xPosition, chunk.zPosition)) {
sink.onDelta(e);
}
sink.onChunkDirty(chunk.xPosition, chunk.zPosition);
}
@SubscribeEvent
public void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END || dirtyColumns.isEmpty()) return;
long now = System.currentTimeMillis();
Set<Long> dirtyChunks = new LinkedHashSet<Long>();
Iterator<Long> it = dirtyColumns.iterator();
while (it.hasNext()) {
long key = it.next();
it.remove();
int wx = (int) (key >> 32);
int wz = (int) key;
Chunk chunk = world.getChunkFromChunkCoords(wx >> 4, wz >> 4);
sink.onDelta(readColumn(chunk, wx, wz, wx & 15, wz & 15, now, DeltaEvent.Source.EVENT));
dirtyChunks.add((((long) (wx >> 4)) << 32) | ((wz >> 4) & 0xFFFFFFFFL));
}
for (long chunkKey : dirtyChunks) {
sink.onChunkDirty((int) (chunkKey >> 32), (int) chunkKey);
}
}
private void markDirty(World eventWorld, int x, int z) {
if (eventWorld != world) return;
long key = ((long) x << 32) | (z & 0xFFFFFFFFL);
dirtyColumns.add(key);
}
}
}
@@ -0,0 +1,55 @@
package com.octoturge.mcmapper.forge1710;
import com.octoturge.mcmapper.common.BackendConnection;
import com.octoturge.mcmapper.common.LinkCodeGenerator;
import com.octoturge.mcmapper.common.protocol.LinkRequest;
import net.minecraft.command.CommandBase;
import net.minecraft.command.ICommandSender;
import net.minecraft.entity.player.EntityPlayerMP;
import net.minecraft.server.MinecraftServer;
import net.minecraft.util.ChatComponentText;
/** {@code /mcmapper link} — see LinkRequest.java's javadoc and MCMapper-Backend's link.ts. */
public class LinkCommand extends CommandBase {
private final BackendConnection connection;
public LinkCommand(BackendConnection connection) {
this.connection = connection;
}
@Override
public String getCommandName() {
return "mcmapper";
}
@Override
public String getCommandUsage(ICommandSender sender) {
return "/mcmapper link";
}
@Override
public int getRequiredPermissionLevel() {
return 0; // any player may link their own account
}
@Override
public void processCommand(ICommandSender sender, String[] args) {
if (args.length == 0 || !"link".equals(args[0])) {
sender.addChatMessage(new ChatComponentText("Usage: /mcmapper link"));
return;
}
if (!(sender instanceof EntityPlayerMP)) {
sender.addChatMessage(new ChatComponentText("Only players can link an account."));
return;
}
EntityPlayerMP player = (EntityPlayerMP) sender;
String code = LinkCodeGenerator.generate();
LinkRequest.AuthMode authMode = MinecraftServer.getServer().isServerInOnlineMode()
? LinkRequest.AuthMode.ONLINE : LinkRequest.AuthMode.OFFLINE;
connection.sendLinkRequest(new LinkRequest(player.getUniqueID().toString(),
player.getGameProfile().getName(), code, authMode));
player.addChatMessage(new ChatComponentText(
"Your MCMapper link code: " + code + " — enter it on the map site within 10 minutes."));
}
}
@@ -1,11 +1,216 @@
package com.octoturge.mcmapper.forge1710;
import com.octoturge.mcmapper.common.ChunkAdapter;
import com.octoturge.mcmapper.common.DefaultBackendConnection;
import com.octoturge.mcmapper.common.ReconciliationScheduler;
import com.octoturge.mcmapper.common.config.MapperConfig;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.PlayerPosition;
import com.octoturge.mcmapper.common.protocol.SectionData;
import cpw.mods.fml.common.Mod;
import cpw.mods.fml.common.event.FMLPreInitializationEvent;
import cpw.mods.fml.common.event.FMLServerStartingEvent;
import cpw.mods.fml.common.event.FMLServerStoppingEvent;
import cpw.mods.fml.common.eventhandler.SubscribeEvent;
import cpw.mods.fml.common.gameevent.TickEvent;
import net.minecraft.entity.player.EntityPlayerMP;
import net.minecraft.server.MinecraftServer;
import net.minecraft.util.MathHelper;
import net.minecraft.world.WorldServer;
import net.minecraftforge.common.DimensionManager;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.common.config.Configuration;
import net.minecraftforge.event.ServerChatEvent;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Stub for the 1.7.10 leaf (Phase 9 — higher priority than 26.1.2, ships after 1.12.2 proves
* the architecture). Not wired to the Forge {@code @Mod} annotation yet since this leaf's
* ForgeGradle 2.1 toolchain isn't active in the root build — see build.gradle.
* Entry point for the 1.7.10 leaf (Phase 9)one Forge/MC generation older than {@code
* forge-1_12_2} (raw block id + metadata, {@code cpw.mods.fml} packages, no {@code IBlockState}).
* Implements the same feature set the 1.12.2 leaf proved (Phases 1-2-3-7-7b: WS connect, column +
* section backfill/flush, chat bridge, {@code /mcmapper link}, reconciliation sweep, throttled
* player positions) against the {@code common} interfaces — see {@link MCMapperMod}'s 1.12.2
* counterpart for the shared design rationale, and {@link Forge1710ChunkAdapter} for what
* actually differs API-wise.
*/
@Mod(modid = MCMapperMod.MOD_ID, name = "MCMapper", version = MCMapperMod.VERSION)
public class MCMapperMod {
public static final String MOD_ID = "mcmapper";
public static final String VERSION = "0.1.0-SNAPSHOT";
private static final Logger LOGGER = LogManager.getLogger(MOD_ID);
private Configuration forgeConfig;
private final MapperConfig config = new MapperConfig();
private DefaultBackendConnection connection;
private Forge1710ChunkAdapter adapter;
private final List<DeltaEvent> pendingDeltas = Collections.synchronizedList(new ArrayList<DeltaEvent>());
private final Set<Long> pendingSectionChunks = Collections.synchronizedSet(new LinkedHashSet<Long>());
private final ReconciliationScheduler reconciliationScheduler = new ReconciliationScheduler();
private MinecraftServer mcServer;
private int ticksSinceFlush = 0;
private int ticksSinceReconciliation = 0;
private int ticksSincePlayerPositions = 0;
@Mod.EventHandler
public void preInit(FMLPreInitializationEvent event) {
forgeConfig = new Configuration(event.getSuggestedConfigurationFile());
loadConfig();
}
private void loadConfig() {
forgeConfig.load();
config.backendUrl = forgeConfig.getString("backendUrl", "network", config.backendUrl,
"WS URL of the MCMapper backend api service");
config.serverToken = forgeConfig.getString("serverToken", "network", config.serverToken,
"Per-server token issued when registering with the backend (see MCMapper-Backend's `bun run seed`)");
config.deltaFlushIntervalTicks = forgeConfig.getInt("deltaFlushIntervalTicks", "network",
config.deltaFlushIntervalTicks, 1, 20 * 60,
"How often (in ticks) to batch and flush block-change deltas to the backend");
config.reconciliationIntervalTicks = forgeConfig.getInt("reconciliationIntervalTicks", "network",
config.reconciliationIntervalTicks, 20, 20 * 60 * 60,
"How often (in ticks) to run a reconciliation sweep, re-reading and resending a " +
"rotating slice of loaded chunks to catch changes event hooks miss " +
"(world-gen, other mods, /fill, etc.)");
config.reconciliationChunksPerSweep = forgeConfig.getInt("reconciliationChunksPerSweep", "network",
config.reconciliationChunksPerSweep, 1, 5000,
"Max chunks to re-read and resend per reconciliation sweep");
config.playerTrackingEnabled = forgeConfig.getBoolean("playerTrackingEnabled", "network",
config.playerTrackingEnabled,
"Whether to send throttled online-player positions to the backend at all. The " +
"backend also has its own per-server admin toggle deciding whether it " +
"relays this on to web viewers — this setting only controls the mod side.");
config.playerPositionIntervalTicks = forgeConfig.getInt("playerPositionIntervalTicks", "network",
config.playerPositionIntervalTicks, 5, 20 * 60,
"How often (in ticks) to send the online-player roster to the backend, when playerTrackingEnabled");
if (forgeConfig.hasChanged()) forgeConfig.save();
}
@Mod.EventHandler
public void serverStarting(FMLServerStartingEvent event) {
if (config.serverToken == null || config.serverToken.isEmpty()) {
LOGGER.warn("MCMapper serverToken is not configured (see config/mcmapper.cfg) — not connecting to backend");
return;
}
mcServer = event.getServer();
connection = new DefaultBackendConnection(LOGGER::info, LOGGER::warn);
connection.connect(config.backendUrl, config.serverToken);
LOGGER.info("MCMapper (1.7.10 leaf) connecting to " + config.backendUrl);
WorldServer overworld = DimensionManager.getWorld(0);
adapter = new Forge1710ChunkAdapter(overworld);
adapter.registerEventHooks(new ChunkAdapter.DeltaSink() {
@Override
public void onDelta(DeltaEvent delta) {
pendingDeltas.add(delta);
}
@Override
public void onChunkDirty(int chunkX, int chunkZ) {
pendingSectionChunks.add((((long) chunkX) << 32) | (chunkZ & 0xFFFFFFFFL));
}
});
Forge1710ChatBridge chatBridge = new Forge1710ChatBridge(event.getServer(), LOGGER);
connection.setChatListener(chatBridge::injectWebChatMessage);
connection.setWaypointShareListener(chatBridge::injectWaypointShare);
event.registerServerCommand(new LinkCommand(connection));
MinecraftForge.EVENT_BUS.register(this);
}
@SubscribeEvent
public void onServerChat(ServerChatEvent event) {
EntityPlayerMP player = (EntityPlayerMP) event.player;
connection.sendChatMessage(player.getUniqueID().toString(), player.getGameProfile().getName(), event.message);
}
@SubscribeEvent
public void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
if (++ticksSinceFlush >= config.deltaFlushIntervalTicks) {
ticksSinceFlush = 0;
flush();
}
if (++ticksSinceReconciliation >= config.reconciliationIntervalTicks) {
ticksSinceReconciliation = 0;
reconcile();
}
if (config.playerTrackingEnabled && ++ticksSincePlayerPositions >= config.playerPositionIntervalTicks) {
ticksSincePlayerPositions = 0;
sendPlayerPositions();
}
}
private void sendPlayerPositions() {
List<PlayerPosition> players = new ArrayList<PlayerPosition>();
for (Object obj : mcServer.getConfigurationManager().playerEntityList) {
EntityPlayerMP player = (EntityPlayerMP) obj;
if (player.dimension != 0) continue;
players.add(new PlayerPosition(player.getUniqueID().toString(), player.getGameProfile().getName(),
MathHelper.floor_double(player.posX), MathHelper.floor_double(player.posY),
MathHelper.floor_double(player.posZ)));
}
connection.sendPlayerPositions(0, players);
}
private void reconcile() {
List<Long> chunkKeys = reconciliationScheduler.next(adapter.loadedChunkKeys(),
config.reconciliationChunksPerSweep);
if (chunkKeys.isEmpty()) return;
String dimensionId = adapter.getDimensionId();
for (long key : chunkKeys) {
int chunkX = (int) (key >> 32);
int chunkZ = (int) key;
List<DeltaEvent> columns = adapter.readChunk(dimensionId, chunkX, chunkZ);
if (!columns.isEmpty()) connection.sendDeltas(columns);
List<SectionData> sections = adapter.readSections(chunkX, chunkZ);
if (!sections.isEmpty()) connection.sendSections(dimensionId, chunkX, chunkZ, sections);
}
}
private void flush() {
List<DeltaEvent> batch;
synchronized (pendingDeltas) {
if (pendingDeltas.isEmpty()) {
batch = null;
} else {
batch = new ArrayList<DeltaEvent>(pendingDeltas);
pendingDeltas.clear();
}
}
if (batch != null) connection.sendDeltas(batch);
List<Long> dirtyChunks;
synchronized (pendingSectionChunks) {
if (pendingSectionChunks.isEmpty()) return;
dirtyChunks = new ArrayList<Long>(pendingSectionChunks);
pendingSectionChunks.clear();
}
String dimensionId = adapter.getDimensionId();
for (long key : dirtyChunks) {
int chunkX = (int) (key >> 32);
int chunkZ = (int) key;
List<SectionData> sections = adapter.readSections(chunkX, chunkZ);
if (!sections.isEmpty()) {
connection.sendSections(dimensionId, chunkX, chunkZ, sections);
}
}
}
@Mod.EventHandler
public void serverStopping(FMLServerStoppingEvent event) {
if (connection != null) connection.disconnect();
}
}
+4 -2
View File
@@ -20,6 +20,8 @@ minecraft_1_12_2_version=1.12.2
forge_1_12_2_version=14.23.5.2847
mcp_1_12_2_mappings=stable_39
# neoforge-26_1 (loader assumed NeoForge — confirm before Phase 10, see plan's Open Assumptions)
# neoforge-26_1 loader confirmed NeoForge (Phase 10); this properties file isn't read by that
# leaf's build (it's a standalone Gradle project, see neoforge-26_1/settings.gradle), kept here
# only as a record of the pinned version alongside the other two leaves'.
minecraft_26_1_version=26.1.2
neoforge_26_1_version=26.1.2
neoforge_26_1_version=26.1.2.94
+21 -5
View File
@@ -1,12 +1,28 @@
// Loader assumed to be NeoForge (see root plan's Open Assumptions — confirm the actual 26.1.x
// loader ecosystem before Phase 10). Uses the modern ModDevGradle plugin; requires a recent JDK
// (21+) unlike the two legacy leaves.
// Loader confirmed NeoForge (Phase 10) — versions like 26.1.2.94 are published on
// maven.neoforged.net. Uses the modern ModDevGradle plugin. Minecraft itself now requires Java
// 25 (bumped from 21 during the 26.x cycle) — see settings.gradle's comment on how the Java 25
// toolchain gets provisioned without needing it pre-installed system-wide.
//
// Standalone build, not part of the root multi-project build — see settings.gradle.
plugins {
id 'net.neoforged.moddev' version '2.0.+'
}
sourceCompatibility = targetCompatibility = JavaVersion.VERSION_21
group = 'com.octoturge.mcmapper'
version = '0.1.0-SNAPSHOT'
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
sourceCompatibility = targetCompatibility = JavaVersion.VERSION_25
sourceSets {
main {
@@ -17,7 +33,7 @@ sourceSets {
}
neoForge {
version = project.neoforge_26_1_version
version = '26.1.2.94'
runs {
client {}
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+25
View File
@@ -0,0 +1,25 @@
// Standalone build — deliberately NOT part of the root multi-project build (see the root
// settings.gradle's comment): ModDevGradle needs Gradle 8+ and a Java 25 toolchain, incompatible
// with the two legacy leaves' Gradle-7/JDK-8-daemon pin within one Gradle invocation. Build this
// leaf from inside this directory, with its own wrapper:
//
// cd neoforge-26_1 && ./gradlew build
//
// The Gradle daemon itself can run on any modern JDK on PATH/JAVA_HOME (8.14.x supports up to
// JDK 23 as the *daemon* JVM) — it does not need to already be JDK 25. The Foojay resolver below
// lets Gradle auto-provision an actual JDK 25 toolchain (into its own GRADLE_USER_HOME cache, not
// a system-wide install) for the compile/run tasks that require Minecraft's own Java 25
// requirement, without needing one pre-installed.
pluginManagement {
repositories {
gradlePluginPortal()
maven { url = 'https://maven.neoforged.net/releases' }
mavenCentral()
}
}
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
}
rootProject.name = 'neoforge-26_1'
@@ -0,0 +1,39 @@
package com.octoturge.mcmapper.neoforge261;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.octoturge.mcmapper.common.BackendConnection;
import com.octoturge.mcmapper.common.LinkCodeGenerator;
import com.octoturge.mcmapper.common.protocol.LinkRequest;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
/**
* {@code /mcmapper link} — see LinkRequest.java's javadoc and MCMapper-Backend's link.ts. Modern
* Brigadier command registration (this era has no {@code CommandBase} to extend), registered from
* {@link MCMapperMod}'s {@code RegisterCommandsEvent} handler.
*/
final class LinkCommand {
private LinkCommand() {
}
static void register(CommandDispatcher<CommandSourceStack> dispatcher, BackendConnection connection) {
dispatcher.register(Commands.literal("mcmapper")
.then(Commands.literal("link")
.executes(ctx -> link(ctx.getSource(), connection))));
}
private static int link(CommandSourceStack source, BackendConnection connection) throws CommandSyntaxException {
ServerPlayer player = source.getPlayerOrException();
String code = LinkCodeGenerator.generate();
LinkRequest.AuthMode authMode = source.getServer().usesAuthentication()
? LinkRequest.AuthMode.ONLINE : LinkRequest.AuthMode.OFFLINE;
connection.sendLinkRequest(new LinkRequest(player.getUUID().toString(),
player.getGameProfile().name(), code, authMode));
source.sendSuccess(() -> Component.literal(
"Your MCMapper link code: " + code + " — enter it on the map site within 10 minutes."), false);
return 1;
}
}
@@ -1,13 +1,43 @@
package com.octoturge.mcmapper.neoforge261;
import com.octoturge.mcmapper.common.ChunkAdapter;
import com.octoturge.mcmapper.common.DefaultBackendConnection;
import com.octoturge.mcmapper.common.ReconciliationScheduler;
import com.octoturge.mcmapper.common.config.MapperConfig;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.PlayerPosition;
import com.octoturge.mcmapper.common.protocol.SectionData;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.ModContainer;
import net.neoforged.fml.common.Mod;
import net.neoforged.fml.config.ModConfig;
import net.neoforged.neoforge.common.ModConfigSpec;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
import net.neoforged.neoforge.event.ServerChatEvent;
import net.neoforged.neoforge.event.server.ServerStartingEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Entry point for the 26.1.2 leaf lowest MVP priority (Phase 10), stubbed structurally now.
* Loader assumed NeoForge; see root plan's Open Assumptions for the verification note.
* Entry point for the 26.1.2 leaf (Phase 10, lowest MVP priority) — post-Flattening, mixin-era
* NeoForge. Implements the same feature set proven by the two legacy leaves against the {@code
* common} interfaces; see {@link Neoforge261ChunkAdapter}'s javadoc for what differs API-wise
* (block-state reads instead of raw id+meta, {@code net.neoforged.*} packages, Brigadier commands
* instead of {@code CommandBase}, {@code ModConfigSpec} instead of legacy Forge's {@code
* Configuration}, constructor-injected event buses instead of {@code @Mod.EventHandler} methods).
*/
@Mod(MCMapperMod.MOD_ID)
public class MCMapperMod {
@@ -15,7 +45,180 @@ public class MCMapperMod {
private static final Logger LOGGER = LoggerFactory.getLogger(MCMapperMod.class);
public MCMapperMod(IEventBus modEventBus) {
LOGGER.info("MCMapper (neoforge-26_1 leaf) scaffolding loaded — no-op until Phase 10");
private final ModConfigSpec.ConfigValue<String> backendUrlSpec;
private final ModConfigSpec.ConfigValue<String> serverTokenSpec;
private final ModConfigSpec.IntValue deltaFlushIntervalTicksSpec;
private final ModConfigSpec.IntValue reconciliationIntervalTicksSpec;
private final ModConfigSpec.IntValue reconciliationChunksPerSweepSpec;
private final ModConfigSpec.BooleanValue playerTrackingEnabledSpec;
private final ModConfigSpec.IntValue playerPositionIntervalTicksSpec;
private final MapperConfig config = new MapperConfig();
private DefaultBackendConnection connection;
private Neoforge261ChunkAdapter adapter;
private final List<DeltaEvent> pendingDeltas = Collections.synchronizedList(new ArrayList<DeltaEvent>());
private final Set<Long> pendingSectionChunks = Collections.synchronizedSet(new LinkedHashSet<Long>());
private final ReconciliationScheduler reconciliationScheduler = new ReconciliationScheduler();
private MinecraftServer mcServer;
private int ticksSinceFlush = 0;
private int ticksSinceReconciliation = 0;
private int ticksSincePlayerPositions = 0;
public MCMapperMod(IEventBus modEventBus, ModContainer container) {
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
backendUrlSpec = builder.comment("WS URL of the MCMapper backend api service")
.define("backendUrl", config.backendUrl);
serverTokenSpec = builder.comment("Per-server token issued when registering with the backend (see MCMapper-Backend's `bun run seed`)")
.define("serverToken", config.serverToken);
deltaFlushIntervalTicksSpec = builder.comment("How often (in ticks) to batch and flush block-change deltas to the backend")
.defineInRange("deltaFlushIntervalTicks", config.deltaFlushIntervalTicks, 1, 20 * 60);
reconciliationIntervalTicksSpec = builder.comment("How often (in ticks) to run a reconciliation sweep, re-reading and resending a " +
"rotating slice of loaded chunks to catch changes event hooks miss (world-gen, other mods, /fill, etc.)")
.defineInRange("reconciliationIntervalTicks", config.reconciliationIntervalTicks, 20, 20 * 60 * 60);
reconciliationChunksPerSweepSpec = builder.comment("Max chunks to re-read and resend per reconciliation sweep")
.defineInRange("reconciliationChunksPerSweep", config.reconciliationChunksPerSweep, 1, 5000);
playerTrackingEnabledSpec = builder.comment("Whether to send throttled online-player positions to the backend at all. The " +
"backend also has its own per-server admin toggle deciding whether it relays this on to web " +
"viewers — this setting only controls the mod side.")
.define("playerTrackingEnabled", config.playerTrackingEnabled);
playerPositionIntervalTicksSpec = builder.comment("How often (in ticks) to send the online-player roster to the backend, when playerTrackingEnabled")
.defineInRange("playerPositionIntervalTicks", config.playerPositionIntervalTicks, 5, 20 * 60);
container.registerConfig(ModConfig.Type.SERVER, builder.build());
NeoForge.EVENT_BUS.register(this);
}
private void loadConfig() {
config.backendUrl = backendUrlSpec.get();
config.serverToken = serverTokenSpec.get();
config.deltaFlushIntervalTicks = deltaFlushIntervalTicksSpec.get();
config.reconciliationIntervalTicks = reconciliationIntervalTicksSpec.get();
config.reconciliationChunksPerSweep = reconciliationChunksPerSweepSpec.get();
config.playerTrackingEnabled = playerTrackingEnabledSpec.get();
config.playerPositionIntervalTicks = playerPositionIntervalTicksSpec.get();
}
@SubscribeEvent
public void serverStarting(ServerStartingEvent event) {
loadConfig();
if (config.serverToken == null || config.serverToken.isEmpty()) {
LOGGER.warn("MCMapper serverToken is not configured (see config/mcmapper-server.toml) — not connecting to backend");
return;
}
mcServer = event.getServer();
connection = new DefaultBackendConnection(LOGGER::info, LOGGER::warn);
connection.connect(config.backendUrl, config.serverToken);
LOGGER.info("MCMapper (neoforge-26_1 leaf) connecting to " + config.backendUrl);
ServerLevel overworld = event.getServer().overworld();
adapter = new Neoforge261ChunkAdapter(overworld);
adapter.registerEventHooks(new ChunkAdapter.DeltaSink() {
@Override
public void onDelta(DeltaEvent delta) {
pendingDeltas.add(delta);
}
@Override
public void onChunkDirty(int chunkX, int chunkZ) {
pendingSectionChunks.add((((long) chunkX) << 32) | (chunkZ & 0xFFFFFFFFL));
}
});
Neoforge261ChatBridge chatBridge = new Neoforge261ChatBridge(event.getServer());
connection.setChatListener(chatBridge::injectWebChatMessage);
connection.setWaypointShareListener(chatBridge::injectWaypointShare);
}
@SubscribeEvent
public void onRegisterCommands(RegisterCommandsEvent event) {
LinkCommand.register(event.getDispatcher(), connection != null ? connection : new com.octoturge.mcmapper.common.BackendConnection.NoOp());
}
@SubscribeEvent
public void onServerChat(ServerChatEvent event) {
if (connection == null) return;
ServerPlayer player = event.getPlayer();
connection.sendChatMessage(player.getUUID().toString(), event.getUsername(), event.getRawText());
}
@SubscribeEvent
public void onServerTick(ServerTickEvent.Post event) {
if (connection == null) return;
if (++ticksSinceFlush >= config.deltaFlushIntervalTicks) {
ticksSinceFlush = 0;
flush();
}
if (++ticksSinceReconciliation >= config.reconciliationIntervalTicks) {
ticksSinceReconciliation = 0;
reconcile();
}
if (config.playerTrackingEnabled && ++ticksSincePlayerPositions >= config.playerPositionIntervalTicks) {
ticksSincePlayerPositions = 0;
sendPlayerPositions();
}
}
private void sendPlayerPositions() {
List<PlayerPosition> players = new ArrayList<PlayerPosition>();
for (ServerPlayer player : mcServer.getPlayerList().getPlayers()) {
if (player.level().dimension() != net.minecraft.world.level.Level.OVERWORLD) continue;
players.add(new PlayerPosition(player.getUUID().toString(), player.getGameProfile().name(),
player.getBlockX(), player.getBlockY(), player.getBlockZ()));
}
connection.sendPlayerPositions(0, players);
}
private void reconcile() {
List<Long> chunkKeys = reconciliationScheduler.next(adapter.loadedChunkKeys(),
config.reconciliationChunksPerSweep);
if (chunkKeys.isEmpty()) return;
String dimensionId = adapter.getDimensionId();
for (long key : chunkKeys) {
int chunkX = (int) (key >> 32);
int chunkZ = (int) key;
List<DeltaEvent> columns = adapter.readChunk(dimensionId, chunkX, chunkZ);
if (!columns.isEmpty()) connection.sendDeltas(columns);
List<SectionData> sections = adapter.readSections(chunkX, chunkZ);
if (!sections.isEmpty()) connection.sendSections(dimensionId, chunkX, chunkZ, sections);
}
}
private void flush() {
List<DeltaEvent> batch;
synchronized (pendingDeltas) {
if (pendingDeltas.isEmpty()) {
batch = null;
} else {
batch = new ArrayList<DeltaEvent>(pendingDeltas);
pendingDeltas.clear();
}
}
if (batch != null) connection.sendDeltas(batch);
List<Long> dirtyChunks;
synchronized (pendingSectionChunks) {
if (pendingSectionChunks.isEmpty()) return;
dirtyChunks = new ArrayList<Long>(pendingSectionChunks);
pendingSectionChunks.clear();
}
String dimensionId = adapter.getDimensionId();
for (long key : dirtyChunks) {
int chunkX = (int) (key >> 32);
int chunkZ = (int) key;
List<SectionData> sections = adapter.readSections(chunkX, chunkZ);
if (!sections.isEmpty()) {
connection.sendSections(dimensionId, chunkX, chunkZ, sections);
}
}
}
@SubscribeEvent
public void serverStopping(ServerStoppingEvent event) {
if (connection != null) connection.disconnect();
}
}
@@ -0,0 +1,27 @@
package com.octoturge.mcmapper.neoforge261;
import com.octoturge.mcmapper.common.ChatBridge;
import com.octoturge.mcmapper.common.protocol.WaypointChatFormatter;
import com.octoturge.mcmapper.common.protocol.WaypointShare;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
public class Neoforge261ChatBridge implements ChatBridge {
private final MinecraftServer server;
public Neoforge261ChatBridge(MinecraftServer server) {
this.server = server;
}
@Override
public void injectWebChatMessage(String displayName, String message) {
server.getPlayerList().broadcastSystemMessage(Component.literal("[Web] " + displayName + ": " + message), false);
}
@Override
public void injectWaypointShare(WaypointShare waypoint) {
// Same rationale as the legacy leaves' ChatBridge: neither JourneyMap's nor Xaero's
// chat-waypoint syntax needs a click-event component, plain text is enough.
server.getPlayerList().broadcastSystemMessage(Component.literal(WaypointChatFormatter.format(waypoint)), false);
}
}
@@ -0,0 +1,191 @@
package com.octoturge.mcmapper.neoforge261;
import com.octoturge.mcmapper.common.ChunkAdapter;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.SectionData;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.LevelAccessor;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.world.level.levelgen.Heightmap;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.event.level.BlockEvent;
import net.neoforged.neoforge.event.level.ChunkEvent;
import net.neoforged.neoforge.event.level.block.BreakBlockEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* 26.1.2's {@link ChunkAdapter} (Phase 10) — post-Flattening: blocks are read as {@link
* BlockState}, not raw id+metadata, and this leaf's block identity ({@code Block.getId(state)},
* a registry-wide packed int) no longer reliably fits the pre-Flattening leaves' {@code (blockId
* << 4) | meta} 16-bit encoding once a heavily-modded registry is in play (see {@link
* DeltaEvent}'s javadoc). {@link DeltaEvent#blockStateId} is a plain {@code int}, so 2D
* column/reconciliation data (the Phase 1/9/10 parity bar) carries the full packed id losslessly;
* {@link SectionData#blocks} is a {@code char[]} for wire-size reasons inherited from the
* pre-Flattening leaves, so 3D section backfill truncates to the low 16 bits — a known,
* documented collision risk for very large (heavily modded) registries, deferred rather than
* redesigning the wire protocol for a Phase whose own verification bar is 2D-tile parity, not 3D.
*
* <p>Also unlike the two legacy leaves, this adapter tracks its own loaded-chunk set from {@link
* ChunkEvent.Load}/{@code Unload} rather than querying a chunk provider's internals directly —
* modern {@code ServerChunkCache} doesn't expose a simple public "currently loaded chunks"
* iterable, and an event-driven set is arguably more robust anyway.
*/
public class Neoforge261ChunkAdapter implements ChunkAdapter {
private final ServerLevel level;
private final String dimensionId;
private final Set<Long> loadedChunks = ConcurrentHashMap.newKeySet();
public Neoforge261ChunkAdapter(ServerLevel level) {
this.level = level;
this.dimensionId = level.dimension().identifier().toString();
}
public String getDimensionId() {
return dimensionId;
}
@Override
public List<DeltaEvent> readChunk(String dimension, int chunkX, int chunkZ) {
LevelChunk chunk = level.getChunk(chunkX, chunkZ);
List<DeltaEvent> events = new ArrayList<DeltaEvent>(256);
long now = System.currentTimeMillis();
for (int lx = 0; lx < 16; lx++) {
for (int lz = 0; lz < 16; lz++) {
events.add(readColumn(chunkX * 16 + lx, chunkZ * 16 + lz, now, DeltaEvent.Source.RECONCILIATION));
}
}
return events;
}
private DeltaEvent readColumn(int worldX, int worldZ, long now, DeltaEvent.Source source) {
int height = level.getHeight(Heightmap.Types.WORLD_SURFACE, worldX, worldZ);
int topY = height - 1;
BlockState state = level.getBlockState(new BlockPos(worldX, topY, worldZ));
int blockStateId = Block.getId(state);
return new DeltaEvent(dimensionId, worldX, topY, worldZ, blockStateId, now, source);
}
@Override
public List<SectionData> readSections(int chunkX, int chunkZ) {
LevelChunk chunk = level.getChunk(chunkX, chunkZ);
LevelChunkSection[] storage = chunk.getSections();
List<SectionData> sections = new ArrayList<SectionData>();
for (int sectionY = 0; sectionY < storage.length; sectionY++) {
LevelChunkSection section = storage[sectionY];
// hasOnlyAir() is the modern equivalent of the legacy leaves' null/isEmpty()
// shortcut — skip fully-air sections without a 4096-position scan.
if (section == null || section.hasOnlyAir()) continue;
char[] blocks = new char[4096];
for (int ly = 0; ly < 16; ly++) {
for (int lz = 0; lz < 16; lz++) {
for (int lx = 0; lx < 16; lx++) {
BlockState state = section.getBlockState(lx, ly, lz);
int blockStateId = Block.getId(state) & 0xFFFF;
blocks[(ly * 16 + lz) * 16 + lx] = (char) blockStateId;
}
}
}
sections.add(new SectionData(sectionY, blocks));
}
return sections;
}
@Override
public void registerEventHooks(DeltaSink sink) {
NeoForge.EVENT_BUS.register(new EventHooks(sink));
}
@Override
public List<Long> loadedChunkKeys() {
return new ArrayList<Long>(loadedChunks);
}
/**
* Same "mark dirty, drain once per tick" strategy as the legacy leaves (see {@code
* Forge1122ChunkAdapter}'s javadoc) — {@code BreakBlockEvent} still fires before the actual
* removal. This era's block events expose {@code getLevel()}/{@code getPos()} methods (not
* the legacy leaves' direct-field or {@code x}/{@code y}/{@code z} conventions), and block
* break/place are two differently-shaped classes ({@code BreakBlockEvent} top-level,
* {@code BlockEvent.EntityPlaceEvent} nested) rather than one shared {@code BlockEvent}
* subclass pair.
*/
private class EventHooks {
private final DeltaSink sink;
private final Set<Long> dirtyColumns = ConcurrentHashMap.newKeySet();
EventHooks(DeltaSink sink) {
this.sink = sink;
}
@SubscribeEvent
public void onBlockBreak(BreakBlockEvent event) {
markDirty(event.getLevel(), event.getPos());
}
@SubscribeEvent
public void onBlockPlace(BlockEvent.EntityPlaceEvent event) {
markDirty(event.getLevel(), event.getPos());
}
@SubscribeEvent
public void onChunkLoad(ChunkEvent.Load event) {
if (event.getLevel() != level) return;
LevelChunk chunk = event.getChunk();
int chunkX = chunk.getPos().x();
int chunkZ = chunk.getPos().z();
loadedChunks.add(key(chunkX, chunkZ));
for (DeltaEvent e : readChunk(dimensionId, chunkX, chunkZ)) {
sink.onDelta(e);
}
sink.onChunkDirty(chunkX, chunkZ);
}
@SubscribeEvent
public void onChunkUnload(ChunkEvent.Unload event) {
if (event.getLevel() != level) return;
LevelChunk chunk = event.getChunk();
loadedChunks.remove(key(chunk.getPos().x(), chunk.getPos().z()));
}
@SubscribeEvent
public void onServerTick(ServerTickEvent.Post event) {
if (dirtyColumns.isEmpty()) return;
long now = System.currentTimeMillis();
Set<Long> dirtyChunks = new LinkedHashSet<Long>();
Iterator<Long> it = dirtyColumns.iterator();
while (it.hasNext()) {
long key = it.next();
it.remove();
int wx = (int) (key >> 32);
int wz = (int) key;
sink.onDelta(readColumn(wx, wz, now, DeltaEvent.Source.EVENT));
dirtyChunks.add(key(wx >> 4, wz >> 4));
}
for (long chunkKey : dirtyChunks) {
sink.onChunkDirty((int) (chunkKey >> 32), (int) chunkKey);
}
}
private void markDirty(LevelAccessor eventLevel, BlockPos pos) {
if (eventLevel != level) return;
dirtyColumns.add(key(pos.getX(), pos.getZ()));
}
}
private static long key(int a, int b) {
return (((long) a) << 32) | (b & 0xFFFFFFFFL);
}
}
+9 -5
View File
@@ -17,8 +17,12 @@ include 'forge-1_12_2'
// neoforge-26_1 is intentionally NOT included in the default build: both legacy leaves
// (forge-1_7_10, forge-1_12_2) use anatawa12's ForgeGradle 1.2/2.3 forks, which need Gradle 7.6
// and JDK 8 (see gradle.properties' org.gradle.java.home pin and this project's
// gradle-wrapper.properties). ModDevGradle (used by neoforge-26_1) needs Gradle 8+ and JDK 17+
// to even apply the plugin — incompatible with that daemon within one invocation. Until Phase 10
// gives it a proper isolated build setup, it can only be built by pointing org.gradle.java.home
// at a JDK 21+ install and invoking Gradle (8+) from inside the neoforge-26_1/ directory
// directly, with its own wrapper. The module itself is fully scaffolded and ready.
// gradle-wrapper.properties). ModDevGradle (used by neoforge-26_1) needs Gradle 8+ and a Java 25
// toolchain (Minecraft itself now requires Java 25 as of the 26.x cycle) — incompatible with
// that daemon within one invocation. Phase 10 gave it its own standalone build instead: it has
// its own wrapper + settings.gradle (with the Foojay toolchain resolver, so Gradle
// auto-provisions the JDK 25 toolchain rather than needing one pre-installed) — build it from
// inside neoforge-26_1/ directly (`cd neoforge-26_1 && ./gradlew build`), pointing
// org.gradle.java.home at a JDK 21+ install for the Gradle daemon itself via a separate
// $GRADLE_USER_HOME (same mechanism as the legacy leaves' JDK 8 pin) — see neoforge-26_1's
// README section for the full command.
+14
View File
@@ -0,0 +1,14 @@
# Copy to .env (untracked) and adjust if the defaults in docker-compose.yml don't fit — none of
# these need to be set for the default `mc-1_12_2` service to work as-is.
# MC_1_12_2_PORT=25565
# MC_1_12_2_MEMORY=3G
# MC_1_12_2_ONLINE_MODE=FALSE
# MC_1_7_10_PORT=25566
# MC_1_7_10_MEMORY=2G
# MC_1_7_10_ONLINE_MODE=FALSE
# MC_NEOFORGE_26_1_PORT=25567
# MC_NEOFORGE_26_1_MEMORY=3G
# MC_NEOFORGE_26_1_ONLINE_MODE=FALSE
+7
View File
@@ -0,0 +1,7 @@
# Real jars/configs/world data dropped in here for local live-testing — never committed. The
# per-loader-per-version directories themselves are tracked via .gitkeep so `docker compose up`
# has somewhere to bind-mount without a manual `mkdir` first.
mods/*/*/*
config/*/*/*
!mods/*/*/.gitkeep
!config/*/*/.gitkeep
+111
View File
@@ -0,0 +1,111 @@
# Live test servers
Real Minecraft servers (via [itzg/docker-minecraft-server](https://github.com/itzg/docker-minecraft-server),
MIT — handles EULA acceptance, Forge/NeoForge installation, memory flags, etc. so this compose
file doesn't reimplement any of that) for exercising the mod end-to-end against a running
MCMapper-Backend stack — the thing the plan's own per-phase verification steps keep asking for
("place/break blocks, confirm they appear on the map") but that no session has actually done yet.
Only `mc-1_12_2` runs by default — it's the actual driving use case (Enigmatica 2, see the plan's
version priority). `mc-1_7_10` and `mc-neoforge-26_1` are opt-in via Compose profiles:
```
docker compose up # 1.12.2 only (default)
docker compose --profile legacy-1_7_10 up # + 1.7.10
docker compose --profile neoforge-26_1 up # + 26.1.2 NeoForge (least tested, lowest priority)
```
## 1. Start a backend stack
From `MCMapper-Backend`, either:
- `docker compose up` (full stack — api/frontend/worker/postgres/redis/caddy; Caddy publishes
`:80` on the host, routing `/ws*` to `api` per the Caddyfile), or
- run `api` directly (`cd api && bun run dev`) for a lighter loop — it listens on `:3000` with no
proxy in front.
Either way, register a server row so the mod has a token to authenticate with:
```
cd MCMapper-Backend/api
MCMAPPER_SEED_SERVER_NAME=test-1_12_2 MCMAPPER_SEED_SERVER_TOKEN=<pick-any-string> \
MCMAPPER_SEED_SERVER_AUTH_MODE=offline \
DATABASE_URL=postgres://mcmapper:mcmapper@localhost:<pg-port>/mcmapper bun run seed
```
(`authMode=offline` matches this compose's default `ONLINE_MODE=FALSE` — see docker-compose.yml's
comment on that variable. Use `online` + `ONLINE_MODE=TRUE` instead to test the real UUID-merge
identity path.) The admin panel (`/admin` on the frontend, Phase 6) is the other way to register a
server, if the backend is already up with `MCMAPPER_ADMIN_TOKEN` set.
## 2. Build the mod and drop the jar in
```
cd MCMapper-Mod
./gradlew :forge-1_12_2:build
cp forge-1_12_2/build/libs/forge-1_12_2-*.jar test/mods/forge/1.12.2/
```
(Swap the leaf/version for `forge-1_7_10`/`mods/forge/1.7.10` or `neoforge-26_1`/`mods/neoforge/26.1.2`
— note `neoforge-26_1` has its own standalone Gradle wrapper, see the root README's "Building". Mods
are split by loader first, then version — Forge and NeoForge mod jars aren't interchangeable even
for adjacent MC versions. Each `mods/<loader>/<version>/` directory maps straight to that server's
`/data/mods` — drop other mod jars in there too to test alongside MCMapper, and remove/swap them any
time, then `docker compose restart` to pick up the change; nothing in `mods/`/`config/` is ever
committed, see .gitignore.)
## 3. First boot, then configure
```
cd test
docker compose up -d
docker compose logs -f mc-1_12_2 # wait for "Done" / world generation to finish
```
The mod writes `config/mcmapper.cfg` with empty defaults on this first boot (it needs a
`backendUrl`/`serverToken` before it'll actually connect — see the root README's "Configuration"
section). Edit `test/config/forge/1.12.2/mcmapper.cfg`:
```
backendUrl=ws://host.docker.internal:80/ws
serverToken=<the token you seeded above>
```
Use `ws://host.docker.internal:80/ws` if the backend is running via its own `docker compose up`
(Caddy on host `:80`), or `ws://host.docker.internal:3000/ws` if you ran `bun run dev` for `api`
directly instead (no Caddy in front). `extra_hosts: host.docker.internal:host-gateway` in
docker-compose.yml is what makes that hostname resolve to the host machine from inside the MC
container — works the same whether the host's Docker is Docker Desktop or a bare Linux daemon
(Engine ≥20.10, which this project's own WSL2 dockerd is).
Then:
```
docker compose restart mc-1_12_2
```
## 4. Verify
Connect a real Minecraft 1.12.2 client to `localhost:${MC_1_12_2_PORT:-25565}` (offline/cracked
login works fine with the default `ONLINE_MODE=FALSE`), walk around to load some chunks, place/
break a few blocks, then check the map frontend (`http://localhost` if using the backend's own
`docker compose`, or `http://localhost:3001` if running `frontend` directly) — this is the actual
Phase 1 verification step from the plan, finally exercised against a real server rather than only
unit/integration tests. Also useful for later phases' own live-test callouts that were flagged but
never actually run: the atlas shader's `invertY` assumption (Phase 12) and real non-cube block
rendering (Phase 13, place/find a vanilla non-cube block like a torch or stairs — or attach a mod
that ships one, e.g. Thaumcraft, by dropping its jar in the same `mods/forge/1.12.2/` directory).
## Notes
- World/server data persists in named Docker volumes (`mc-1_12_2-data` etc.) across
`docker compose restart`/`down` — use `docker compose down -v` to fully wipe a server and start
over (e.g. after bumping the mod jar in a way that needs a clean world).
- `mods/<loader>/<version>/` and `config/<loader>/<version>/` contents are gitignored (except the
`.gitkeep` placeholders that keep the mount points present in a fresh checkout) — nothing dropped
in them is committed.
- To add other mods for stress-testing (Thaumcraft was the plan's named non-cube-model stress
test for Phase 13) drop their jars in the same `mods/forge/1.12.2/` directory itzg's image reads
from — no compose changes needed. itzg's image also supports `CURSEFORGE_FILES`/`MODRINTH_PROJECTS`
for auto-downloading specific mods (CurseForge's downloads generally need a `CF_API_KEY` now);
not wired up here since that needs per-mod IDs/an API key only you can supply — see itzg's own
docs (https://docker-minecraft-server.readthedocs.io/) if you want that instead of manual jars.
View File
View File
+121
View File
@@ -0,0 +1,121 @@
# Real Minecraft servers for live-testing the mod against a running MCMapper-Backend stack —
# see README.md in this directory for the full walkthrough (build the mod, seed a backend server
# token, drop the jar in mods/<loader>/<version>/, docker compose up). Mods are split by loader
# first, then version — Forge and NeoForge mod jars aren't interchangeable even for adjacent MC
# versions, and a future Fabric leaf (Phase 14) would add its own mods/fabric/<version> alongside
# these rather than needing a reshuffle.
#
# Uses itzg/docker-minecraft-server (https://github.com/itzg/docker-minecraft-server, MIT) — the
# de facto standard MC server image, handles EULA/Forge/NeoForge installation, memory flags, etc.
# so this compose file doesn't have to reimplement any of that.
#
# Only `mc-1_12_2` (the actual driving use case — Enigmatica 2, see the plan's version priority)
# runs by default. The other two leaves are opt-in via Compose profiles, since running all three
# at once is a lot of RAM for a local dev box:
# docker compose up # 1.12.2 only (default)
# docker compose --profile legacy-1_7_10 up # + 1.7.10
# docker compose --profile neoforge-26_1 up # + 26.1.2 (NeoForge) — least tested, lowest MVP priority
services:
mc-1_12_2:
# itzg's `:latest` tag now defaults to a Java 25 base — legacy Forge's LaunchWrapper hard-
# crashes on anything past Java 8 (`ClassCastException: AppClassLoader cannot be cast to
# URLClassLoader`, confirmed by actually booting this service against `:latest` first). Same
# JDK 8 constraint as this repo's own build toolchain (see ../gradle.properties), just for the
# server *runtime* instead of the compiler this time.
image: itzg/minecraft-server:java8
tty: true
stdin_open: true
ports:
- "${MC_1_12_2_PORT:-25565}:25565"
environment:
EULA: "TRUE"
TYPE: "FORGE"
VERSION: "1.12.2"
# Same build pinned in ../gradle.properties (forge_1_12_2_version) — keep these in sync.
FORGE_VERSION: "14.23.5.2847"
MEMORY: "${MC_1_12_2_MEMORY:-3G}"
# Offline/cracked mode by default so any MC client can connect with no Microsoft account
# needed for a quick local smoke test — matches the plan's authMode="offline" identity
# scoping (see MCMapper-Backend's README). Flip to TRUE (and register the backend server
# row with authMode=online) to test the real online-mode UUID-merge path instead.
ONLINE_MODE: "${MC_1_12_2_ONLINE_MODE:-FALSE}"
volumes:
- mc-1_12_2-data:/data
# Drop the built forge-1_12_2 jar (see README.md — `./gradlew :forge-1_12_2:build`,
# output in ../forge-1_12_2/build/libs/) — and any other mod jars to stress-test against
# (e.g. Thaumcraft, the plan's named Phase 13 non-cube-model target) — straight into this
# directory; itzg's image loads anything here as-is, no repackaging needed. Add/remove jars
# any time and `docker compose restart mc-1_12_2` to pick up changes.
- ./mods/forge/1.12.2:/data/mods
# The mod writes config/mcmapper.cfg here on first boot (see the mod repo README's
# "Configuration" section) — edit backendUrl/serverToken in this mounted file, then
# `docker compose restart mc-1_12_2`.
- ./config/forge/1.12.2:/data/config
# Lets `backendUrl=ws://host.docker.internal:<port>/ws` in the mounted config reach a backend
# stack running on the host (either MCMapper-Backend's own `docker compose up`, published on
# host port 80 via its Caddy, or a bare `bun run dev` api on host port 3000) — see README.md.
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
mc-1_7_10:
# Same Java-8 requirement as mc-1_12_2 above — 1.7.10's Forge/LaunchWrapper generation is
# even older, so it needs this at least as much.
image: itzg/minecraft-server:java8
profiles: ["legacy-1_7_10"]
tty: true
stdin_open: true
ports:
- "${MC_1_7_10_PORT:-25566}:25565"
environment:
EULA: "TRUE"
TYPE: "FORGE"
VERSION: "1.7.10"
# Same build pinned in ../gradle.properties (forge_1_7_10_version).
FORGE_VERSION: "10.13.4.1614-1.7.10"
MEMORY: "${MC_1_7_10_MEMORY:-2G}"
ONLINE_MODE: "${MC_1_7_10_ONLINE_MODE:-FALSE}"
volumes:
- mc-1_7_10-data:/data
- ./mods/forge/1.7.10:/data/mods
- ./config/forge/1.7.10:/data/config
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
mc-neoforge-26_1:
# Unlike the two legacy leaves above, this one actually *wants* a modern JRE (Minecraft
# itself requires Java 25 as of the 26.x cycle, see ../neoforge-26_1/settings.gradle) — plain
# `:latest` already resolves to a java25 base as of this writing (confirmed by actually
# pulling it), so no separate tag pin needed here.
image: itzg/minecraft-server:latest
profiles: ["neoforge-26_1"]
tty: true
stdin_open: true
ports:
- "${MC_NEOFORGE_26_1_PORT:-25567}:25565"
environment:
EULA: "TRUE"
TYPE: "NEOFORGE"
VERSION: "26.1.2"
# Same build pinned in ../gradle.properties (neoforge_26_1_version). This is the least
# exercised leaf (lowest MVP priority, see the plan) — if itzg's installer doesn't yet
# recognize this exact MC/NeoForge version pairing, check for an itzg image update first.
NEOFORGE_VERSION: "26.1.2.94"
MEMORY: "${MC_NEOFORGE_26_1_MEMORY:-3G}"
ONLINE_MODE: "${MC_NEOFORGE_26_1_ONLINE_MODE:-FALSE}"
volumes:
- mc-neoforge-26_1-data:/data
- ./mods/neoforge/26.1.2:/data/mods
# NeoForge writes config/mcmapper-server.toml (TOML, not the legacy leaves' .cfg) — see
# the mod repo README's "Configuration" section.
- ./config/neoforge/26.1.2:/data/config
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
volumes:
mc-1_12_2-data:
mc-1_7_10-data:
mc-neoforge-26_1-data:
View File
View File
View File