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()).
This commit is contained in:
2026-08-09 22:44:06 +02:00
parent 0d8e670cd8
commit 52417a7b92
13 changed files with 918 additions and 31 deletions
+42 -15
View File
@@ -18,8 +18,12 @@ in Claude Code's plan mode.
`common` interfaces as `forge-1_12_2`. Builds with legacy ForgeGradle 1.2, same fork family, one `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 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.*`). `IBlockState`) and pre-FML-repackage (`cpw.mods.fml.*`, not `net.minecraftforge.fml.*`).
- `neoforge-26_1/` — MC 26.1.2 (NeoForge, assumed). Lowest MVP priority. Structurally scaffolded, - `neoforge-26_1/` — MC 26.1.2 (Phase 10; loader confirmed NeoForge). Lowest MVP priority, fully
not yet wired into the default build (see `settings.gradle`). 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 ## Version targets and priority
@@ -40,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 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 take a while / needs network access.
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.
## Configuration (forge-1_12_2 and forge-1_7_10) `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: On first server start the leaf writes `config/mcmapper.cfg` with defaults. Set:
@@ -59,15 +79,22 @@ overworld chunks, and streams event-driven column deltas (block place/break) in
`deltaFlushIntervalTicks` (default 20 = 1s). A periodic reconciliation sweep (Phase 7) also `deltaFlushIntervalTicks` (default 20 = 1s). A periodic reconciliation sweep (Phase 7) also
walks a bounded number of currently-loaded chunks per tick (`reconciliationChunksPerSweep`, 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 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 mutations event hooks miss (world-gen, other mods, `/fill`). Only the overworld is tracked. The
other dimensions and `neoforge-26_1` land in a later phase. The WS client and JSON encoding are WS client and JSON encoding are hand-rolled (no third-party dependency) — see
hand-rolled (no third-party dependency) — see
`common/src/main/java/.../ws/SimpleWebSocketClient.java` and `.../json/MiniJson.java` for why. `common/src/main/java/.../ws/SimpleWebSocketClient.java` and `.../json/MiniJson.java` for why.
`forge-1_7_10` implements the identical config/flush/reconciliation/player-tracking behavior
against 1.7.10's own API generation (see `Forge1710ChunkAdapter`'s javadoc for what differs) — it `forge-1_7_10` and `neoforge-26_1` implement the identical config/flush/reconciliation/
has no `MultiPlaceEvent` hook (that class doesn't reliably exist at this Forge version), so player-tracking behavior against their own API generation (see `Forge1710ChunkAdapter`'s and
multi-block placements like doors/beds are only caught by the reconciliation sweep rather than `Neoforge261ChunkAdapter`'s javadocs for what differs). Neither has a `MultiPlaceEvent`-equivalent
immediately, unlike `forge-1_12_2`. 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) ### Player position tracking (Phase 7b)
+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 forge_1_12_2_version=14.23.5.2847
mcp_1_12_2_mappings=stable_39 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 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 confirmed NeoForge (Phase 10) — versions like 26.1.2.94 are published on
// loader ecosystem before Phase 10). Uses the modern ModDevGradle plugin; requires a recent JDK // maven.neoforged.net. Uses the modern ModDevGradle plugin. Minecraft itself now requires Java
// (21+) unlike the two legacy leaves. // 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 { plugins {
id 'net.neoforged.moddev' version '2.0.+' 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 { sourceSets {
main { main {
@@ -17,7 +33,7 @@ sourceSets {
} }
neoForge { neoForge {
version = project.neoforge_26_1_version version = '26.1.2.94'
runs { runs {
client {} 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; 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.IEventBus;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.ModContainer;
import net.neoforged.fml.common.Mod; 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.Logger;
import org.slf4j.LoggerFactory; 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. * Entry point for the 26.1.2 leaf (Phase 10, lowest MVP priority) — post-Flattening, mixin-era
* Loader assumed NeoForge; see root plan's Open Assumptions for the verification note. * 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) @Mod(MCMapperMod.MOD_ID)
public class MCMapperMod { public class MCMapperMod {
@@ -15,7 +45,180 @@ public class MCMapperMod {
private static final Logger LOGGER = LoggerFactory.getLogger(MCMapperMod.class); private static final Logger LOGGER = LoggerFactory.getLogger(MCMapperMod.class);
public MCMapperMod(IEventBus modEventBus) { private final ModConfigSpec.ConfigValue<String> backendUrlSpec;
LOGGER.info("MCMapper (neoforge-26_1 leaf) scaffolding loaded — no-op until Phase 10"); 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 // 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 // (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 // 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+ // gradle-wrapper.properties). ModDevGradle (used by neoforge-26_1) needs Gradle 8+ and a Java 25
// to even apply the plugin — incompatible with that daemon within one invocation. Until Phase 10 // toolchain (Minecraft itself now requires Java 25 as of the 26.x cycle) — incompatible with
// gives it a proper isolated build setup, it can only be built by pointing org.gradle.java.home // that daemon within one invocation. Phase 10 gave it its own standalone build instead: it has
// at a JDK 21+ install and invoking Gradle (8+) from inside the neoforge-26_1/ directory // its own wrapper + settings.gradle (with the Foojay toolchain resolver, so Gradle
// directly, with its own wrapper. The module itself is fully scaffolded and ready. // 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.