diff --git a/README.md b/README.md index 94dc2b2..0d22ef1 100644 --- a/README.md +++ b/README.md @@ -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 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 (NeoForge, assumed). Lowest MVP priority. Structurally scaffolded, - not yet wired into the default build (see `settings.gradle`). +- `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 @@ -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 -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 (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: @@ -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 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 — -other dimensions and `neoforge-26_1` land in a later phase. The WS client and JSON encoding are -hand-rolled (no third-party dependency) — see +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` 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 -has no `MultiPlaceEvent` hook (that class doesn't reliably exist at this Forge version), so -multi-block placements like doors/beds are only caught by the reconciliation sweep rather than -immediately, unlike `forge-1_12_2`. + +`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) diff --git a/gradle.properties b/gradle.properties index fade719..d9b6a13 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/neoforge-26_1/build.gradle b/neoforge-26_1/build.gradle index a0f5bf3..3876c2b 100644 --- a/neoforge-26_1/build.gradle +++ b/neoforge-26_1/build.gradle @@ -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 {} diff --git a/neoforge-26_1/gradle/wrapper/gradle-wrapper.jar b/neoforge-26_1/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/neoforge-26_1/gradle/wrapper/gradle-wrapper.jar differ diff --git a/neoforge-26_1/gradle/wrapper/gradle-wrapper.properties b/neoforge-26_1/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/neoforge-26_1/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/neoforge-26_1/gradlew b/neoforge-26_1/gradlew new file mode 100644 index 0000000..d95bf61 --- /dev/null +++ b/neoforge-26_1/gradlew @@ -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" "$@" diff --git a/neoforge-26_1/gradlew.bat b/neoforge-26_1/gradlew.bat new file mode 100644 index 0000000..640d686 --- /dev/null +++ b/neoforge-26_1/gradlew.bat @@ -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 diff --git a/neoforge-26_1/settings.gradle b/neoforge-26_1/settings.gradle new file mode 100644 index 0000000..947ab62 --- /dev/null +++ b/neoforge-26_1/settings.gradle @@ -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' diff --git a/neoforge-26_1/src/main/java/com/octoturge/mcmapper/neoforge261/LinkCommand.java b/neoforge-26_1/src/main/java/com/octoturge/mcmapper/neoforge261/LinkCommand.java new file mode 100644 index 0000000..c82af80 --- /dev/null +++ b/neoforge-26_1/src/main/java/com/octoturge/mcmapper/neoforge261/LinkCommand.java @@ -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 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; + } +} diff --git a/neoforge-26_1/src/main/java/com/octoturge/mcmapper/neoforge261/MCMapperMod.java b/neoforge-26_1/src/main/java/com/octoturge/mcmapper/neoforge261/MCMapperMod.java index 8dd0867..1324fe1 100644 --- a/neoforge-26_1/src/main/java/com/octoturge/mcmapper/neoforge261/MCMapperMod.java +++ b/neoforge-26_1/src/main/java/com/octoturge/mcmapper/neoforge261/MCMapperMod.java @@ -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 backendUrlSpec; + private final ModConfigSpec.ConfigValue 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 pendingDeltas = Collections.synchronizedList(new ArrayList()); + private final Set 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; + + 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 players = new ArrayList(); + 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 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 columns = adapter.readChunk(dimensionId, chunkX, chunkZ); + if (!columns.isEmpty()) connection.sendDeltas(columns); + List sections = adapter.readSections(chunkX, chunkZ); + if (!sections.isEmpty()) connection.sendSections(dimensionId, chunkX, chunkZ, sections); + } + } + + private void flush() { + List batch; + synchronized (pendingDeltas) { + if (pendingDeltas.isEmpty()) { + batch = null; + } else { + batch = new ArrayList(pendingDeltas); + pendingDeltas.clear(); + } + } + if (batch != null) connection.sendDeltas(batch); + + List dirtyChunks; + synchronized (pendingSectionChunks) { + if (pendingSectionChunks.isEmpty()) return; + dirtyChunks = new ArrayList(pendingSectionChunks); + pendingSectionChunks.clear(); + } + String dimensionId = adapter.getDimensionId(); + for (long key : dirtyChunks) { + int chunkX = (int) (key >> 32); + int chunkZ = (int) key; + List 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(); } } diff --git a/neoforge-26_1/src/main/java/com/octoturge/mcmapper/neoforge261/Neoforge261ChatBridge.java b/neoforge-26_1/src/main/java/com/octoturge/mcmapper/neoforge261/Neoforge261ChatBridge.java new file mode 100644 index 0000000..1509bee --- /dev/null +++ b/neoforge-26_1/src/main/java/com/octoturge/mcmapper/neoforge261/Neoforge261ChatBridge.java @@ -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); + } +} diff --git a/neoforge-26_1/src/main/java/com/octoturge/mcmapper/neoforge261/Neoforge261ChunkAdapter.java b/neoforge-26_1/src/main/java/com/octoturge/mcmapper/neoforge261/Neoforge261ChunkAdapter.java new file mode 100644 index 0000000..26ab9ae --- /dev/null +++ b/neoforge-26_1/src/main/java/com/octoturge/mcmapper/neoforge261/Neoforge261ChunkAdapter.java @@ -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. + * + *

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 loadedChunks = ConcurrentHashMap.newKeySet(); + + public Neoforge261ChunkAdapter(ServerLevel level) { + this.level = level; + this.dimensionId = level.dimension().identifier().toString(); + } + + public String getDimensionId() { + return dimensionId; + } + + @Override + public List readChunk(String dimension, int chunkX, int chunkZ) { + LevelChunk chunk = level.getChunk(chunkX, chunkZ); + List events = new ArrayList(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 readSections(int chunkX, int chunkZ) { + LevelChunk chunk = level.getChunk(chunkX, chunkZ); + LevelChunkSection[] storage = chunk.getSections(); + List sections = new ArrayList(); + 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 loadedChunkKeys() { + return new ArrayList(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 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 dirtyChunks = new LinkedHashSet(); + Iterator 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); + } +} diff --git a/settings.gradle b/settings.gradle index 9cf0907..cbcf0cb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -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.