File size: 18,450 Bytes
eee14a2 8f6b7b7 eee14a2 8f6b7b7 407b4fa 7837484 8f6b7b7 eee14a2 8f6b7b7 eee14a2 8f6b7b7 eee14a2 407b4fa eee14a2 8f6b7b7 eee14a2 8f6b7b7 eee14a2 8f6b7b7 eee14a2 8f6b7b7 eee14a2 8f6b7b7 eee14a2 8f6b7b7 eee14a2 8f6b7b7 eee14a2 8f6b7b7 eee14a2 407b4fa 7837484 407b4fa 7837484 407b4fa eee14a2 407b4fa eee14a2 407b4fa eee14a2 8f6b7b7 eee14a2 407b4fa eee14a2 407b4fa eee14a2 8f6b7b7 eee14a2 7837484 eee14a2 8f6b7b7 eee14a2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 | package space.minecraft;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.loader.api.FabricLoader;
import net.fabricmc.loader.api.MappingResolver;
import java.lang.reflect.*;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
/** Local telemetry/control adapter. Gameplay remains in the upstream AltoClef mod.
*
* <p>Threading contract: every read or mutation of Minecraft client state
* ({@link #snapshot}, {@link #connect}, AltoClef commands) runs ONLY on the
* Minecraft client thread, reached through {@code Executor} dispatch (the
* client implements {@code java.util.concurrent.Executor}) with a reflective
* {@code execute(Runnable)} fallback. Dispatch is never run inline on a
* telemetry or HTTP thread: a failed dispatch records a diagnostic and the
* bridge keeps reporting a loading/error heartbeat WITHOUT claiming fresh
* connected world observations.
*
* <p>Freshness contract for /state: {@code heartbeat_at} is refreshed by the
* scheduler tick itself (bridge alive), while {@code observed_at} is refreshed
* only by a snapshot that actually ran on the client thread. At most one
* snapshot is ever queued, so rendering load cannot build a backlog.
*
* <p>Connect contract: the client starts at the normal TitleScreen (no
* {@code --server/--port}, which would bypass it and leave upstream
* uninitialized) and this bridge connects only on a Title/Disconnected
* screen with no resource reload overlay (connecting mid-reload NPEs
* WorldRenderer via ShaderProgram.addSampler), on the client thread only.
*/
public final class SpaceBridge implements ClientModInitializer {
private static final MappingResolver MAP = FabricLoader.getInstance().getMappingResolver();
private volatile String state = "{\"connected\":false,\"loading\":true,\"heartbeat_at\":0}";
private volatile String lastError = "";
private volatile String lastCommand = "";
private final AtomicBoolean snapshotPending = new AtomicBoolean(false);
private long connectAttempt;
private long connectAttempts;
private volatile String connectState = "startup";
private String lastGate = "";
private long creditsSince;
private boolean creditsObserved;
private boolean enteredEnd;
private String previousDimension = "";
private static Class<?> mc(String intermediary) throws Exception {
return Class.forName(MAP.mapClassName("intermediary", "net.minecraft." + intermediary));
}
private static Method accessibleMethod(Class<?> owner, String name, Class<?>[] types) throws Exception {
try {
return owner.getMethod(name, types);
} catch (NoSuchMethodException missing) {
Method fallback = owner.getDeclaredMethod(name, types);
fallback.setAccessible(true);
return fallback;
}
}
private static Object method(Object obj, String owner, String id, String desc,
Class<?>[] types, Object... args) throws Exception {
String name = MAP.mapMethodName("intermediary", "net.minecraft." + owner, id, desc);
return accessibleMethod(mc(owner), name, types).invoke(obj, args);
}
private static Object call(Object obj, String name, Class<?>[] types, Object... args) throws Exception {
return accessibleMethod(obj.getClass(), name, types).invoke(obj, args);
}
private static Object call(Object obj, String name) throws Exception {
return call(obj, name, new Class<?>[0]);
}
private static Object client() throws Exception {
return method(null, "class_310", "method_1551", "()Lnet/minecraft/class_310;", new Class<?>[0]);
}
/** Enqueue on the client thread. Never runs the task inline: throws on failure. */
private static void dispatchToClient(Object client, Runnable task) throws Exception {
if (client instanceof Executor) {
((Executor) client).execute(task);
return;
}
accessibleMethod(client.getClass(), "execute", new Class<?>[]{Runnable.class}).invoke(client, task);
}
private static Object screen(Object client) throws Exception {
String name = MAP.mapFieldName("intermediary", "net.minecraft.class_310", "field_1755", "Lnet/minecraft/class_437;");
try {
return mc("class_310").getField(name).get(client);
} catch (NoSuchFieldException missing) {
Field fallback = mc("class_310").getDeclaredField(name);
fallback.setAccessible(true);
return fallback.get(client);
}
}
private static void respond(HttpExchange ex, int code, String body) throws Exception {
byte[] data = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
ex.sendResponseHeaders(code, data.length);
ex.getResponseBody().write(data);
ex.close();
}
/** Loading/error heartbeat: fresh heartbeat_at, no connected observations claimed. */
private void heartbeat(String detail) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("connected", false);
data.put("loading", true);
data.put("heartbeat_at", System.currentTimeMillis() / 1000.0);
data.put("dispatch_error", detail == null ? "" : detail);
data.put("error", lastError);
data.put("last_command", lastCommand);
state = json(data);
}
private void queueSnapshot() {
// At most one pending snapshot: skip the tick when the client thread
// has not drained the previous one (e.g. under rendering load).
if (!snapshotPending.compareAndSet(false, true)) {
heartbeat("snapshot still queued on client thread");
return;
}
boolean queued = false;
try {
dispatchToClient(client(), () -> {
try {
snapshot();
} finally {
snapshotPending.set(false);
}
});
queued = true;
} catch (Exception err) {
lastError = "client dispatch failed: " + err;
} finally {
if (!queued) {
snapshotPending.set(false);
heartbeat(lastError);
}
}
}
@Override public void onInitializeClient() {
try {
int port = Integer.parseInt(System.getenv().getOrDefault("ALTOCLEF_BRIDGE_PORT", "3002"));
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 8);
server.createContext("/state", ex -> {
try { respond(ex, 200, state); } catch (Exception ignored) { ex.close(); }
});
server.createContext("/command", ex -> {
try {
if (!"POST".equals(ex.getRequestMethod())) { respond(ex, 405, "{}"); return; }
String command = new String(ex.getRequestBody().readNBytes(128), StandardCharsets.UTF_8).trim();
if (!Set.of("gamer", "stop", "status").contains(command)) {
respond(ex, 400, "{\"error\":\"unsupported command\"}"); return;
}
try {
dispatchToClient(client(), () -> {
try {
Class<?> alto = Class.forName("adris.altoclef.AltoClef");
Object executor = alto.getMethod("getCommandExecutor").invoke(null);
if (executor == null) throw new IllegalStateException("AltoClef is not initialized");
executor.getClass().getMethod("executeWithPrefix", String.class).invoke(executor, command);
lastCommand = command;
lastError = "";
System.out.println("[space-altoclef] command=" + command);
} catch (Exception err) { lastError = err.toString(); }
});
} catch (Exception err) {
lastError = "command dispatch failed: " + err;
respond(ex, 503, json(Map.of("error", lastError)));
return;
}
respond(ex, 202, "{\"accepted\":true}");
} catch (Exception err) {
try { respond(ex, 503, json(Map.of("error", err.toString()))); } catch (Exception ignored) { ex.close(); }
}
});
server.setExecutor(Executors.newFixedThreadPool(2));
server.start();
Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "space-altoclef-telemetry"); t.setDaemon(true); return t;
}).scheduleWithFixedDelay(() -> {
try {
queueSnapshot();
} catch (Throwable err) {
lastError = "telemetry scheduler: " + err;
heartbeat(lastError);
}
}, 2, 1, TimeUnit.SECONDS);
System.out.println("[space-altoclef] local bridge ready on 127.0.0.1:" + port);
} catch (Exception err) { throw new RuntimeException("Space bridge failed", err); }
}
private void connect(Object client, Object currentScreen) throws Exception {
// Gated connect: NEVER while a resource/shader reload overlay
// is up (that NPEs WorldRenderer via ShaderProgram.addSampler on a
// half-built reload), never while vanilla is already connecting, and
// never before upstream initialized on the TitleScreen.
// Pinned Yarn 1.19.4+build.2: METHOD emh ()Lesx; aJ method_18506 getOverlay
// (owner class_310 MinecraftClient, returns class_4071 Overlay).
Object overlay = method(client, "class_310", "method_18506", "()Lnet/minecraft/class_4071;",
new Class<?>[0]);
boolean title = currentScreen != null
&& (mc("class_442").isInstance(currentScreen) || mc("class_419").isInstance(currentScreen));
boolean connecting = currentScreen != null && mc("class_412").isInstance(currentScreen);
String screenName = currentScreen == null ? "<none>" : currentScreen.getClass().getName();
if (overlay != null) {
gate("waiting resource reload (overlay=" + overlay.getClass().getName() + ")");
return;
}
if (connecting) {
connectState = "connecting (vanilla ConnectScreen)";
return;
}
if (!title) {
gate("waiting title screen (screen=" + screenName + ")");
return;
}
if (System.currentTimeMillis() - connectAttempt < 30000) {
connectState = "retry waits 30s throttle (screen=" + screenName + ")";
return;
}
connectAttempt = System.currentTimeMillis();
connectAttempts++;
String address = System.getenv().getOrDefault("ALTOCLEF_SERVER", "127.0.0.1:25568");
Object parsed = method(null, "class_639", "method_2950", "(Ljava/lang/String;)Lnet/minecraft/class_639;",
new Class<?>[]{String.class}, address);
Object info = mc("class_642").getConstructor(String.class, String.class, boolean.class)
.newInstance("Minecraft Friends", address, false);
method(null, "class_412", "method_36877",
"(Lnet/minecraft/class_437;Lnet/minecraft/class_310;Lnet/minecraft/class_639;Lnet/minecraft/class_642;)V",
new Class<?>[]{mc("class_437"), mc("class_310"), mc("class_639"), mc("class_642")},
screen(client), client, parsed, info);
connectState = "connecting to " + address + " (attempt " + connectAttempts + ")";
System.out.println("[space-altoclef] connecting to " + address + " (attempt " + connectAttempts + ")");
}
/** Throttled gate diagnostic: log only when the reason changes. */
private void gate(String reason) {
connectState = reason;
if (!reason.equals(lastGate)) {
lastGate = reason;
System.out.println("[space-altoclef] connect gated: " + reason);
}
}
private String currentDimension() throws Exception {
// Upstream WorldHelper.getCurrentDimension is package-private static;
// resolve it as a declared method so snapshots survive visibility.
Class<?> helper = Class.forName("adris.altoclef.util.helpers.WorldHelper");
return String.valueOf(accessibleMethod(helper, "getCurrentDimension", new Class<?>[0]).invoke(null));
}
/** Runs ONLY on the Minecraft client thread. Refreshes observed_at. */
private void snapshot() {
Map<String,Object> data = new LinkedHashMap<>();
try {
Object client = client();
Class<?> alto = Class.forName("adris.altoclef.AltoClef");
Object mod = alto.getMethod("getInstance").invoke(null);
Object executor = alto.getMethod("getCommandExecutor").invoke(null);
Object player = mod == null ? null : call(mod, "getPlayer");
data.put("connected", player != null);
data.put("initialized", executor != null);
data.put("last_command", lastCommand);
data.put("error", lastError);
Object currentScreen = screen(client);
boolean credits = currentScreen != null && mc("class_445").isInstance(currentScreen);
if (credits) {
if (enteredEnd) creditsObserved = true;
if (creditsSince == 0) creditsSince = System.currentTimeMillis();
if (System.currentTimeMillis() - creditsSince > 12000) {
method(currentScreen, "class_437", "method_25419", "()V", new Class<?>[0]);
}
}
data.put("credits_screen", credits);
data.put("credits_observed", creditsObserved);
data.put("entered_end", enteredEnd);
data.put("connect_state", connectState);
data.put("connect_attempts", connectAttempts);
if (player == null) {
if (executor != null && !credits) connect(client, currentScreen);
} else {
String dimension = currentDimension();
if ("END".equals(dimension)) enteredEnd = true;
data.put("dimension", dimension);
data.put("returned_from_end", enteredEnd && "OVERWORLD".equals(dimension));
if (!dimension.equals(previousDimension)) {
System.out.println("[space-altoclef] dimension=" + dimension);
previousDimension = dimension;
}
data.put("position", Map.of("x", method(player,"class_1297","method_23317","()D",new Class<?>[0]),
"y", method(player,"class_1297","method_23318","()D",new Class<?>[0]),
"z", method(player,"class_1297","method_23321","()D",new Class<?>[0])));
data.put("health", method(player,"class_1309","method_6032","()F",new Class<?>[0]));
Object controller = call(mod, "getController");
data.put("creative", method(controller,"class_636","method_2914","()Z",new Class<?>[0]));
Object inventory = method(player,"class_1657","method_31548","()Lnet/minecraft/class_1661;",new Class<?>[0]);
List<Object> items = new ArrayList<>();
for (int slot = 0; slot < 41; slot++) {
Object stack = method(inventory,"class_1263","method_5438","(I)Lnet/minecraft/class_1799;",new Class<?>[]{int.class},slot);
int count = (Integer) method(stack,"class_1799","method_7947","()I",new Class<?>[0]);
if (count > 0) items.add(Map.of("slot",slot,"item",String.valueOf(method(stack,"class_1799","method_7909","()Lnet/minecraft/class_1792;",new Class<?>[0])),"count",count));
}
data.put("inventory", items);
Object chain = call(mod,"getUserTaskChain");
if (chain == null) {
// Upstream builds the task chain on TitleScreen entry; a
// null chain during loading reports initialized=false with
// diagnostics, never an NPE, and never a fabricated
// task_active.
data.put("initialized", false);
data.put("task_active", false);
data.put("init_note", "taskchain not yet initialized (waiting TitleScreen entry)");
} else {
data.put("task_active", call(chain,"isActive"));
data.put("tasks", String.valueOf(call(chain,"getTasks")));
}
data.put("entered_end", enteredEnd);
lastError = "";
data.put("error", "");
}
} catch (Exception err) {
lastError = err.toString();
data.put("error", lastError);
}
data.put("heartbeat_at", System.currentTimeMillis() / 1000.0);
data.put("observed_at", System.currentTimeMillis() / 1000.0);
state = json(data);
}
private static String json(Object value) {
if (value == null) return "null";
if (value instanceof Boolean || value instanceof Number) return value.toString();
if (value instanceof Map<?,?> map) {
StringJoiner parts = new StringJoiner(",", "{", "}");
map.forEach((k,v) -> parts.add(json(String.valueOf(k)) + ":" + json(v)));
return parts.toString();
}
if (value instanceof Collection<?> list) {
StringJoiner parts = new StringJoiner(",", "[", "]");
list.forEach(v -> parts.add(json(v))); return parts.toString();
}
return "\"" + value.toString().replace("\\","\\\\").replace("\"","\\\"")
.replace("\n","\\n").replace("\r","\\r").replace("\t","\\t") + "\"";
}
}
|