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") + "\"";
    }
}