Compare commits

...
13 Commits
Author SHA1 Message Date
selimaj-dev 5df42e2b7c Merge pull request 'Downscale title-screen panorama faces from 4096 to 2048' (#4) from downscale-panorama into master
Reviewed-on: #4
2026-09-25 16:35:37 +00:00
selimaj-devandclaude b3fc524763 Downscale title-screen panorama faces from 4096 to 2048
The six 4096x4096 faces were 110 MB, about 90% of every mod jar
(121.7 MB per Minecraft version). That exceeded the 100 MB upload limit
in front of git.selimaj.dev, made every download huge, and cost ~400 MB
of GPU memory on the title screen. At 2048x2048 they're 33.5 MB (jar
~45 MB, ~100 MB GPU memory) and still sharp on 1440p/4K screens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 18:30:14 +02:00
selimaj-dev 98da42992e Merge pull request 'Authenticate through Mojang's session server; check client version' (#3) from mojang-session-auth into master
Reviewed-on: #3
2026-09-25 13:24:47 +00:00
selimaj-devandclaude 2de394ebbf Authenticate through Mojang's session server; check client version
Stop sending the Minecraft access token to the Saturn server. The
connect loop now:

1. fetches GET /versions and compares this client's mod version:
   supported -> connect; deprecated -> warn, connect; neither -> warn,
   don't connect (warnings are shown once per status change)
2. requests auth_challenge with the username to get a server id
3. calls Mojang's session `join` with the access token and server id
   (the token only goes to Mojang); a refused token gives up
4. requests auth_verify, which the server confirms with `hasJoined`

Add SaturnProvider.getModVersion() and showWarning() for the version
check and its toast.

Refs saturnclientmc/saturnclient#7

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 15:22:37 +02:00
selimaj-dev 510760d590 Merge pull request 'Reconnect to the Saturn server and time out requests' (#2) from reconnect-and-timeouts into master
Reviewed-on: #2
2026-09-25 12:48:46 +00:00
selimaj-devandclaude 3b4e1ab4cb Reconnect to the Saturn server and time out requests
Move to session-java 0.2.0 (Gitea package registry) and rework
ServiceClient around a single connect loop:

- Reconnect with exponential backoff (1 s -> 60 s) when the connection
  drops, e.g. on every server redeploy; give up only when retrying cannot
  help (no Minecraft session, or the server rejects the token)
- Publish the session only after authentication succeeds, and replace
  (rather than append to) owned cosmetics and clear cached players on
  reconnect
- Time out every request after 10 s and connecting after 10 s, so a lost
  response can no longer hang the player-lookup worker forever
- Register onClientStopping once, and keep a local session reference in
  request callbacks
- Allow -Dsaturn.serverUri to point the client at a local server

Refs saturnclientmc/saturnclient#4, saturnclientmc/saturnclient#5

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 14:46:30 +02:00
selimaj-dev 8cb458c9bd Merge pull request 'Stop per-frame player lookups and make shared state thread-safe' (#1) from fix-player-lookups into master
Reviewed-on: #1
2026-09-25 12:01:26 +00:00
selimaj-devandclaude 9e3bf14ffa Stop per-frame player lookups and make shared state thread-safe
SaturnPlayer stored non-Saturn players as null, which looked the same as
"not fetched yet", so every render re-queued them and flooded the server
with get_player requests. Remember negative lookups and re-check them
after 60 s, retry failed lookups after 5 s, queue each player at most
once, skip lookups while disconnected, and replace the hand-rolled worker
thread with a single-thread executor.

getPlayer now returns null only when the server says the player isn't on
Saturn and throws on failure, so failures are retried instead of cached.

Make state shared between network callbacks and the render thread safe:
ConcurrentHashMap for players, CopyOnWriteArrayList for owned cosmetics,
volatile session/uuid and cosmetic fields, and run emote notifications
on the client thread.

Refs saturnclientmc/saturnclient#2, saturnclientmc/saturnclient#6

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 14:00:29 +02:00
Kr4ight 2cfd93c197 Fix inputs 2026-04-09 01:27:52 +02:00
selimaj-dev 1ddfd9d024 Improved auth 2026-04-08 10:41:24 +02:00
selimaj-dev 5e44a71054 Added implementation for identifier as effect icon 2026-04-08 08:27:13 +02:00
selimaj-dev 5dd4b17db5 Merge pull request #8 from saturnclientmc/fix-auth-timeout
Hopefully fixed auth timeout
2026-04-07 20:16:00 +02:00
Kr4ight 3379acef80 Hopefully fixed auth timeout 2026-04-07 20:14:56 +02:00
17 changed files with 538 additions and 206 deletions
+1 -1
View File
@@ -7,5 +7,5 @@ java {
} }
dependencies { dependencies {
implementation "com.github.selimaj-dev:session-java:0.1.6" implementation "dev.selimaj:session-java:0.2.0"
} }
@@ -0,0 +1,60 @@
package org.saturnclient.client;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.UUID;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
/**
* Mojang's session server "join" call, the same one vanilla makes when joining
* an online-mode server. The access token is only ever sent to Mojang; the
* Saturn server then confirms the join with {@code hasJoined}.
*/
public final class MojangSession {
private static final URI JOIN_URI = URI.create("https://sessionserver.mojang.com/session/minecraft/join");
private static final ObjectMapper MAPPER = new ObjectMapper();
public enum JoinResult {
JOINED,
/** Mojang refused the access token; retrying won't help. */
REJECTED,
/** Network or server error; worth retrying. */
FAILED
}
private MojangSession() {
}
public static JoinResult join(HttpClient http, String accessToken, UUID profile, String serverId) {
ObjectNode body = MAPPER.createObjectNode()
.put("accessToken", accessToken)
.put("selectedProfile", profile.toString().replace("-", ""))
.put("serverId", serverId);
HttpRequest request = HttpRequest.newBuilder(JOIN_URI)
.timeout(Duration.ofSeconds(10))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString()))
.build();
try {
int status = http.send(request, HttpResponse.BodyHandlers.discarding()).statusCode();
if (status == 204 || status == 200) {
return JoinResult.JOINED;
}
return status == 401 || status == 403 ? JoinResult.REJECTED : JoinResult.FAILED;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return JoinResult.FAILED;
} catch (Exception e) {
return JoinResult.FAILED;
}
}
}
@@ -1,7 +1,15 @@
package org.saturnclient.client; package org.saturnclient.client;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.saturnclient.client.player.SaturnPlayer; import org.saturnclient.client.player.SaturnPlayer;
import org.saturnclient.common.provider.Providers; import org.saturnclient.common.provider.Providers;
@@ -9,174 +17,392 @@ import org.saturnclient.common.ref.game.MinecraftClientRef;
import org.saturnclient.cosmetics.Cloaks; import org.saturnclient.cosmetics.Cloaks;
import org.saturnclient.cosmetics.Hats; import org.saturnclient.cosmetics.Hats;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.selimaj.session.Session; import dev.selimaj.session.Session;
import dev.selimaj.session.types.SessionErrorException;
public class ServiceClient { public class ServiceClient {
private static Session session; /** Override with -Dsaturn.serverUri=ws://127.0.0.1:8080 to use a local server. */
public static UUID uuid; private static final String SERVER_URI = System.getProperty("saturn.serverUri", "wss://saturn-server.selimaj.dev");
private static final long CONNECT_TIMEOUT_SECONDS = 10;
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
private static final long MIN_RECONNECT_DELAY_MS = 1_000;
private static final long MAX_RECONNECT_DELAY_MS = 60_000;
public static boolean connectTimeout() { /** The authenticated session, or null while disconnected or connecting. */
try { private static volatile Session session;
session = Session.connect("wss://saturn-server.selimaj.dev", 10, TimeUnit.SECONDS); public static volatile UUID uuid;
return true;
} catch (Exception e) { private static final AtomicBoolean CONNECTING = new AtomicBoolean();
e.printStackTrace(); private static volatile boolean stopping = false;
return false;
} private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
.build();
private static final ObjectMapper MAPPER = new ObjectMapper();
private enum VersionStatus {
SUPPORTED,
/** Still accepted, but the player should update. */
DEPRECATED,
/** Not accepted; don't connect. */
UNSUPPORTED
} }
public static boolean authenticate() { /** Last status the player was warned about, so reconnects don't repeat the toast. */
MinecraftClientRef client = Providers.saturn.getClient(); private static volatile VersionStatus warnedStatus = VersionStatus.SUPPORTED;
client.onClientStopping(() -> { public static void initialize() {
if (session != null) { Providers.saturn.getClient().onClientStopping(() -> {
stopping = true;
Session current = session;
session = null;
if (current != null) {
try { try {
session.close(); current.close();
} catch (Exception e) { } catch (Exception e) {
Providers.saturn.logError("Unable to close Saturn session", e); Providers.saturn.logError("Unable to close Saturn session", e);
} }
} }
}); });
try { connectInBackground();
String accessToken = client.getAccessToken(); }
uuid = client.getUuid();
String username = client.getUsername();
if (accessToken == null || uuid == null || username == null) { public static boolean isConnected() {
Providers.saturn.logError("No active Minecraft session found"); return session != null;
return false; }
}
Providers.saturn.logInfo("Authenticating with UUID: " + uuid); /** Starts the connect loop unless one is already running. */
private static void connectInBackground() {
if (!connectTimeout()) { if (stopping || !CONNECTING.compareAndSet(false, true)) {
Providers.saturn.logError("Unable to authenticate: Session Server Timeout"); return;
return false;
}
ServiceMethods.Types.Player response = session.request(ServiceMethods.Authenticate, accessToken)
.get();
for (String availableCloak : response.cloaks()) {
Cloaks.availableCloaks.add(availableCloak);
}
for (String availableHat : response.hats()) {
Hats.availableHats.add(availableHat);
}
eventHandlers();
SaturnPlayer.set(new SaturnPlayer(uuid, username, response.cloak(), response.hat()));
return true;
} catch (Exception e) {
Providers.saturn.logError("Authentication failed", e);
return false;
} }
Thread thread = new Thread(() -> {
boolean connected = false;
try {
connected = connectLoop();
} finally {
CONNECTING.set(false);
}
// A drop between connecting and clearing CONNECTING couldn't start a new loop.
if (connected && session == null) {
connectInBackground();
}
}, "Saturn-Connect");
thread.setDaemon(true);
thread.start();
}
/**
* Connects and authenticates, retrying with exponential backoff. Returns
* whether it connected.
*/
private static boolean connectLoop() {
long delay = MIN_RECONNECT_DELAY_MS;
while (!stopping) {
switch (connectOnce()) {
case CONNECTED -> {
Providers.saturn.logInfo("Saturn Session initialization complete");
return true;
}
case GIVE_UP -> {
Providers.saturn.logError("Failed to authenticate with the server");
return false;
}
case RETRY -> {
Providers.saturn.logError("Saturn server unavailable, retrying in " + delay / 1000 + "s");
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
return false;
}
delay = Math.min(delay * 2, MAX_RECONNECT_DELAY_MS);
}
}
}
return false;
}
private enum ConnectResult {
CONNECTED,
/** Retrying cannot help, e.g. no Minecraft session or the token was rejected. */
GIVE_UP,
RETRY
}
private static ConnectResult connectOnce() {
MinecraftClientRef client = Providers.saturn.getClient();
String accessToken = client.getAccessToken();
UUID playerUuid = client.getUuid();
String username = client.getUsername();
if (accessToken == null || playerUuid == null || username == null) {
Providers.saturn.logError("No active Minecraft session found");
return ConnectResult.GIVE_UP;
}
uuid = playerUuid;
VersionStatus version;
try {
version = checkVersion();
} catch (Exception e) {
Providers.saturn.logError("Unable to fetch the Saturn version manifest: " + rootMessage(e));
return ConnectResult.RETRY;
}
if (version == VersionStatus.UNSUPPORTED) {
return ConnectResult.GIVE_UP;
}
Providers.saturn.logInfo("Authenticating with UUID: " + playerUuid);
Session s;
try {
s = Session.connect(SERVER_URI, CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (Exception e) {
Providers.saturn.logError("Unable to connect to the Saturn server: " + rootMessage(e));
return ConnectResult.RETRY;
}
ServiceMethods.Types.Player response;
try {
// 1. The server hands out a one-time server id for this connection.
String serverId;
try {
serverId = s.request(ServiceMethods.AuthChallenge, username, REQUEST_TIMEOUT).get();
} catch (ExecutionException e) {
s.close();
if (e.getCause() instanceof SessionErrorException rejected) {
Providers.saturn.logError("Saturn server rejected authentication: " + rejected.getMessage());
return ConnectResult.GIVE_UP;
}
Providers.saturn.logError("Authentication failed: " + rootMessage(e));
return ConnectResult.RETRY;
}
// 2. Join with it at Mojang; the access token only goes to Mojang.
switch (MojangSession.join(HTTP, accessToken, playerUuid, serverId)) {
case JOINED -> {
}
case REJECTED -> {
s.close();
Providers.saturn.logError("Mojang rejected the Minecraft session");
return ConnectResult.GIVE_UP;
}
case FAILED -> {
s.close();
Providers.saturn.logError("Unable to reach the Mojang session server");
return ConnectResult.RETRY;
}
}
// 3. The server confirms the join with Mojang and logs us in.
try {
response = s.request(ServiceMethods.AuthVerify, null, REQUEST_TIMEOUT).get();
} catch (ExecutionException e) {
s.close();
Providers.saturn.logError("Authentication failed: " + rootMessage(e));
return ConnectResult.RETRY;
}
} catch (InterruptedException e) {
s.close();
return ConnectResult.GIVE_UP;
}
Cloaks.availableCloaks.clear();
Cloaks.availableCloaks.addAll(java.util.List.of(response.cloaks()));
Cloaks.initialize();
Hats.availableHats.clear();
Hats.availableHats.addAll(java.util.List.of(response.hats()));
Hats.initialize();
// Other players may have changed cosmetics while we were disconnected.
SaturnPlayer.clear();
SaturnPlayer.set(new SaturnPlayer(playerUuid, username, response.cloak(), response.hat()));
eventHandlers(s);
if (stopping) {
s.close();
return ConnectResult.GIVE_UP;
}
session = s;
// Registered after publishing so an immediate drop still clears it.
s.onClose(() -> onDisconnected(s));
return ConnectResult.CONNECTED;
}
/**
* Checks this client's version against the server's manifest and warns the
* player if it's deprecated or unsupported.
*/
private static VersionStatus checkVersion() throws Exception {
URI server = URI.create(SERVER_URI);
String scheme = "wss".equals(server.getScheme()) ? "https" : "http";
URI manifestUri = new URI(scheme, server.getAuthority(), "/versions", null, null);
HttpResponse<String> res = HTTP.send(
HttpRequest.newBuilder(manifestUri).timeout(REQUEST_TIMEOUT).GET().build(),
HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) {
throw new IllegalStateException("GET /versions returned " + res.statusCode());
}
ServiceMethods.VersionManifest manifest = MAPPER.readValue(res.body(), ServiceMethods.VersionManifest.class);
// Drop build metadata such as "+1.21.11": the manifest lists mod versions.
String version = Providers.saturn.getModVersion().split("\\+", 2)[0];
VersionStatus status;
if (manifest.supported() != null && manifest.supported().contains(version)) {
status = VersionStatus.SUPPORTED;
} else if (manifest.deprecated() != null && manifest.deprecated().contains(version)) {
status = VersionStatus.DEPRECATED;
} else {
status = VersionStatus.UNSUPPORTED;
}
if (status != warnedStatus) {
warnedStatus = status;
switch (status) {
case DEPRECATED -> {
Providers.saturn.logError("Saturn Client " + version + " is deprecated");
Providers.saturn.showWarning("Saturn Client update available",
"Version " + version + " is deprecated and will stop working soon. Please update.");
}
case UNSUPPORTED -> {
Providers.saturn.logError("Saturn Client " + version + " is no longer supported, not connecting");
Providers.saturn.showWarning("Saturn Client is outdated",
"Version " + version + " is no longer supported. Update to use cosmetics and emotes.");
}
case SUPPORTED -> {
}
}
}
return status;
}
private static void onDisconnected(Session closed) {
if (session != closed) {
return;
}
session = null;
if (!stopping) {
Providers.saturn.logError("Disconnected from the Saturn server, reconnecting");
connectInBackground();
}
}
private static String rootMessage(Throwable e) {
while ((e instanceof CompletionException || e instanceof ExecutionException) && e.getCause() != null) {
e = e.getCause();
}
return e.getClass().getSimpleName() + ": " + e.getMessage();
} }
public static void setCloak(String itemId) { public static void setCloak(String itemId) {
if (session == null) Session s = session;
if (s == null)
return; return;
try { s.request(ServiceMethods.SetCloak, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
session.request(ServiceMethods.SetCloak, itemId).whenComplete((msg, throwable) -> { if (throwable != null) {
if (throwable != null) { Providers.saturn.logError("Failed to set cloak (service): " + rootMessage(throwable));
throwable.printStackTrace(); } else {
} else { sendPlayer(s);
try { }
session.request(ServiceMethods.SendPlayer, });
new ServiceMethods.Types.SendPlayerRequest(SaturnPlayer.getExternalUUIDAsString()));
} catch (Exception e) {
e.printStackTrace();
}
}
});
} catch (Exception e) {
Providers.saturn.logError("Failed to set cloak (service): ", e);
}
} }
public static void setHat(String itemId) { public static void setHat(String itemId) {
if (session == null) Session s = session;
if (s == null)
return; return;
try { s.request(ServiceMethods.SetHat, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
session.request(ServiceMethods.SetHat, itemId).whenComplete((msg, throwable) -> { if (throwable != null) {
if (throwable != null) { Providers.saturn.logError("Failed to set hat (service): " + rootMessage(throwable));
throwable.printStackTrace(); } else {
} else { sendPlayer(s);
try { }
session.request(ServiceMethods.SendPlayer, });
new ServiceMethods.Types.SendPlayerRequest(SaturnPlayer.getExternalUUIDAsString())); }
} catch (Exception e) {
e.printStackTrace(); /** Tells nearby Saturn players about our current cosmetics. */
private static void sendPlayer(Session s) {
s.request(ServiceMethods.SendPlayer,
new ServiceMethods.Types.SendPlayerRequest(SaturnPlayer.getExternalUUIDAsString()),
REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
if (throwable != null) {
Providers.saturn.logError("Failed to send player (service): " + rootMessage(throwable));
} }
} });
});
} catch (Exception e) {
Providers.saturn.logError("Failed to set hat (service): ", e);
}
} }
public static void buyCloak(String itemId) { public static void buyCloak(String itemId) {
if (session == null) Session s = session;
if (s == null)
return; return;
try { s.request(ServiceMethods.BuyCloak, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
session.request(ServiceMethods.BuyCloak, itemId).whenComplete((msg, throwable) -> { if (throwable != null) {
if (throwable != null) { Providers.saturn.logError("Failed to buy cloak (service): " + rootMessage(throwable));
throwable.printStackTrace(); } else if (!Cloaks.availableCloaks.contains(itemId)) {
} else { Cloaks.availableCloaks.add(itemId);
Cloaks.availableCloaks.add(itemId); }
} });
});
} catch (Exception e) {
Providers.saturn.logError("Failed to buy cloak (service): ", e);
}
} }
public static void buyHat(String itemId) { public static void buyHat(String itemId) {
if (session == null) Session s = session;
if (s == null)
return; return;
try { s.request(ServiceMethods.BuyHat, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
session.request(ServiceMethods.BuyHat, itemId).whenComplete((msg, throwable) -> { if (throwable != null) {
if (throwable != null) { Providers.saturn.logError("Failed to buy hat (service): " + rootMessage(throwable));
throwable.printStackTrace(); } else if (!Hats.availableHats.contains(itemId)) {
} else { Hats.availableHats.add(itemId);
Hats.availableHats.add(itemId); }
} });
});
} catch (Exception e) {
Providers.saturn.logError("Failed to buy hat (service): ", e);
}
} }
public static void emote(String emote) { public static void emote(String emote) {
if (session == null) Session s = session;
if (s == null)
return; return;
try { s.request(ServiceMethods.Emote,
session.request(ServiceMethods.Emote, new ServiceMethods.Types.EmoteRequest(emote, SaturnPlayer.getExternalUUIDAsString()),
new ServiceMethods.Types.EmoteRequest(emote, SaturnPlayer.getExternalUUIDAsString())) REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
.whenComplete((msg, throwable) -> { if (throwable != null) {
if (throwable != null) { Providers.saturn.logError("Failed to emote (service): " + rootMessage(throwable));
throwable.printStackTrace(); }
} });
});
} catch (Exception e) {
Providers.saturn.logError("Failed to emote (service): ", e);
}
} }
public static void eventHandlers() { private static void eventHandlers(Session s) {
if (session == null) s.onNotification(ServiceMethods.EmoteEvent, (data) -> {
return;
session.onNotification(ServiceMethods.EmoteEvent, (data) -> {
if (data == null) { if (data == null) {
return; return;
} }
@@ -187,34 +413,34 @@ public class ServiceClient {
return; return;
} }
if (data.emote() != null && !data.emote().isEmpty()) { String emote = data.emote() != null && !data.emote().isEmpty() ? data.emote() : null;
Providers.saturn.playEmote(from, data.emote());
} else { // Notifications arrive on a background thread.
Providers.saturn.playEmote(from, null); Providers.saturn.getClient().executeOnThread(() -> Providers.saturn.playEmote(from, emote));
}
}); });
session.onNotification(ServiceMethods.Player, (player) -> { s.onNotification(ServiceMethods.Player, (player) -> {
System.out.println(player);
SaturnPlayer.set(player.toSaturnPlayer()); SaturnPlayer.set(player.toSaturnPlayer());
}); });
} }
public static SaturnPlayer getPlayer(UUID uuid, String name) { /**
if (session == null) * Returns the player's Saturn data, or null if the server says they aren't
* using Saturn. Throws if the lookup itself fails or times out, so callers
* can retry.
*/
public static SaturnPlayer getPlayer(UUID uuid, String name) throws Exception {
Session s = session;
if (s == null)
throw new IllegalStateException("Not connected to the Saturn server");
ServiceMethods.Types.Player player = s.request(ServiceMethods.GetPlayer, uuid.toString(), REQUEST_TIMEOUT)
.join();
if (player == null)
return null; return null;
try { return player.toSaturnPlayer(uuid, name);
ServiceMethods.Types.Player player = session.request(ServiceMethods.GetPlayer, uuid.toString()).join();
if (player == null)
return null;
return player.toSaturnPlayer(uuid, name);
} catch (Exception e) {
Providers.saturn.logError("Failed to get player", e);
}
return null;
} }
} }
@@ -43,11 +43,25 @@ public class ServiceMethods {
// ========================= // =========================
// AUTH // AUTH
// ========================= // =========================
public static final Method<String, Types.Player, String> Authenticate = new Method<>("auth", /** Username in, random server id out, to pass to Mojang's session join. */
public static final Method<String, String, String> AuthChallenge = new Method<>("auth_challenge",
String.class, String.class,
String.class,
String.class);
/** After joining at Mojang: the server verifies with hasJoined. */
public static final Method<Void, Types.Player, String> AuthVerify = new Method<>("auth_verify",
Void.class,
Types.Player.class, Types.Player.class,
String.class); String.class);
// =========================
// VERSION MANIFEST (GET /versions)
// =========================
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
public record VersionManifest(java.util.List<String> supported, java.util.List<String> deprecated) {
}
// ========================= // =========================
// EQUIP // EQUIP
// ========================= // =========================
@@ -1,24 +1,39 @@
package org.saturnclient.client.player; package org.saturnclient.client.player;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Queue; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.saturnclient.client.ServiceClient; import org.saturnclient.client.ServiceClient;
import org.saturnclient.common.provider.Providers; import org.saturnclient.common.provider.Providers;
public class SaturnPlayer { public class SaturnPlayer {
private static final Map<UUID, SaturnPlayer> PLAYERS = new HashMap<>(); /** Players known to be using Saturn. */
private static final Map<UUID, SaturnPlayer> PLAYERS = new ConcurrentHashMap<>();
private static final Queue<UUID> QUEUE = new ConcurrentLinkedQueue<>(); /**
* Earliest time (ms) a player without a Saturn entry may be looked up again,
* so non-Saturn players aren't re-requested every frame.
*/
private static final Map<UUID, Long> NEXT_LOOKUP = new ConcurrentHashMap<>();
private static volatile boolean RUNNING = false; /** Players queued or being looked up. */
private static Thread WORKER; private static final Set<UUID> PENDING = ConcurrentHashMap.newKeySet();
public String cloak = ""; private static final long NOT_SATURN_RECHECK_MS = 60_000;
public String hat = ""; private static final long FAILED_LOOKUP_RETRY_MS = 5_000;
private static final ExecutorService LOOKUPS = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "SaturnPlayer-Worker");
thread.setDaemon(true);
return thread;
});
public volatile String cloak = "";
public volatile String hat = "";
public final UUID uuid; public final UUID uuid;
public final String name; public final String name;
@@ -30,12 +45,18 @@ public class SaturnPlayer {
} }
public static SaturnPlayer get() { public static SaturnPlayer get() {
if (ServiceClient.uuid == null) { UUID self = ServiceClient.uuid;
if (self == null) {
return null; return null;
} }
return PLAYERS.get(ServiceClient.uuid); return PLAYERS.get(self);
} }
/**
* Returns the player if they are known to use Saturn, otherwise null. An
* unknown player is looked up in the background, at most once at a time and
* no more often than {@link #NEXT_LOOKUP} allows. Safe to call every frame.
*/
public static SaturnPlayer get(String name, UUID uuid) { public static SaturnPlayer get(String name, UUID uuid) {
if (uuid == null) if (uuid == null)
return null; return null;
@@ -43,9 +64,7 @@ public class SaturnPlayer {
SaturnPlayer player = PLAYERS.get(uuid); SaturnPlayer player = PLAYERS.get(uuid);
if (player == null) { if (player == null) {
PLAYERS.put(uuid, null); requestLookup(name, uuid);
QUEUE.add(uuid);
startPlayerThread();
} }
return player; return player;
@@ -59,60 +78,54 @@ public class SaturnPlayer {
return get(name, Providers.saturn.getClient().getPlayerListEntryUUID(name)); return get(name, Providers.saturn.getClient().getPlayerListEntryUUID(name));
} }
/** Forgets every cached player, e.g. after reconnecting to the server. */
public static void clear() {
PLAYERS.clear();
NEXT_LOOKUP.clear();
}
public static void set(SaturnPlayer player) { public static void set(SaturnPlayer player) {
PLAYERS.put(player.uuid, player); PLAYERS.put(player.uuid, player);
NEXT_LOOKUP.remove(player.uuid);
} }
public static String[] getExternalUUIDAsString() { public static String[] getExternalUUIDAsString() {
return PLAYERS.keySet().stream().filter(id -> ServiceClient.uuid == null || !id.equals(ServiceClient.uuid)) UUID self = ServiceClient.uuid;
return PLAYERS.keySet().stream().filter(id -> self == null || !id.equals(self))
.map(UUID::toString) .map(UUID::toString)
.toArray(String[]::new); .toArray(String[]::new);
} }
public static synchronized void startPlayerThread() { private static void requestLookup(String name, UUID uuid) {
if (RUNNING) if (!ServiceClient.isConnected()) {
return; return;
}
RUNNING = true; Long next = NEXT_LOOKUP.get(uuid);
if (next != null && System.currentTimeMillis() < next) {
return;
}
WORKER = new Thread(() -> { if (!PENDING.add(uuid)) {
long lastWorkTime = System.currentTimeMillis(); return;
}
while (true) { LOOKUPS.execute(() -> {
UUID uuid = QUEUE.poll(); try {
SaturnPlayer player = ServiceClient.getPlayer(uuid, name);
if (uuid != null) { if (player != null) {
try { set(player);
String name = Providers.saturn.getClient().getPlayerListEntry(uuid);
SaturnPlayer player = ServiceClient.getPlayer(uuid, name);
// Apply on main thread
Providers.saturn.getClient().executeOnThread(() -> {
PLAYERS.put(uuid, player);
});
} catch (Exception e) {
Providers.saturn.logError("Failed to fetch player " + uuid, e);
}
lastWorkTime = System.currentTimeMillis();
} else { } else {
if (System.currentTimeMillis() - lastWorkTime > 5000) { NEXT_LOOKUP.put(uuid, System.currentTimeMillis() + NOT_SATURN_RECHECK_MS);
break;
}
try {
Thread.sleep(50);
} catch (InterruptedException ignored) {
}
} }
} catch (Exception e) {
Providers.saturn.logError("Failed to fetch player " + uuid, e);
NEXT_LOOKUP.put(uuid, System.currentTimeMillis() + FAILED_LOOKUP_RETRY_MS);
} finally {
PENDING.remove(uuid);
} }
});
RUNNING = false;
}, "SaturnPlayer-Worker");
WORKER.setDaemon(true);
WORKER.start();
} }
} }
@@ -18,4 +18,10 @@ public interface SaturnProvider {
public void logError(String message, Throwable throwable); public void logError(String message, Throwable throwable);
public void registerBufferedImageTexture(IdentifierRef i, BufferedImage bi); public void registerBufferedImageTexture(IdentifierRef i, BufferedImage bi);
/** The Saturn Client mod version, e.g. {@code 0.1.0-beta3+1.21.11}. */
public String getModVersion();
/** Shows a warning toast. Safe to call from any thread. */
public void showWarning(String title, String message);
} }
@@ -1,5 +1,6 @@
package org.saturnclient.common.ref.game; package org.saturnclient.common.ref.game;
import org.saturnclient.common.ref.asset.IdentifierRef;
import org.saturnclient.common.ref.asset.SpriteRef; import org.saturnclient.common.ref.asset.SpriteRef;
public interface EffectRef { public interface EffectRef {
@@ -7,6 +8,10 @@ public interface EffectRef {
SpriteRef getIcon(); SpriteRef getIcon();
default IdentifierRef getIconId() {
return null;
}
boolean isInfinite(); boolean isInfinite();
String getInfiniteText(); String getInfiniteText();
@@ -5,6 +5,7 @@ import org.saturnclient.client.player.SaturnPlayer;
import org.saturnclient.common.ref.asset.IdentifierRef; import org.saturnclient.common.ref.asset.IdentifierRef;
import java.util.*; import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
public class Cloaks { public class Cloaks {
public static final String[] ALL_CLOAKS = { public static final String[] ALL_CLOAKS = {
@@ -30,7 +31,8 @@ public class Cloaks {
"black_hole_flame", "black_hole_flame",
"black_hole_white" }; "black_hole_white" };
public static final List<String> availableCloaks = new ArrayList<>(); // Written by network callbacks, read by the UI thread.
public static final List<String> availableCloaks = new CopyOnWriteArrayList<>();
public static void initialize() { public static void initialize() {
availableCloaks.add(0, ""); availableCloaks.add(0, "");
@@ -1,7 +1,7 @@
package org.saturnclient.cosmetics; package org.saturnclient.cosmetics;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.saturnclient.client.ServiceClient; import org.saturnclient.client.ServiceClient;
import org.saturnclient.client.player.SaturnPlayer; import org.saturnclient.client.player.SaturnPlayer;
@@ -9,7 +9,8 @@ import org.saturnclient.client.player.SaturnPlayer;
public class Hats { public class Hats {
public static final String[] ALL_HATS = { "horns_black", "horns_white", "halo_white", "halo_black", "horns_end", public static final String[] ALL_HATS = { "horns_black", "horns_white", "halo_white", "halo_black", "horns_end",
"halo_end", "bucket_black", "bucket_end" }; "halo_end", "bucket_black", "bucket_end" };
public static List<String> availableHats = new ArrayList<>(); // Written by network callbacks, read by the UI thread.
public static List<String> availableHats = new CopyOnWriteArrayList<>();
public static void initialize() { public static void initialize() {
availableHats.add(0, ""); availableHats.add(0, "");
@@ -4,6 +4,7 @@ import java.util.List;
import org.saturnclient.common.provider.FeatureProvider; import org.saturnclient.common.provider.FeatureProvider;
import org.saturnclient.common.provider.Providers; import org.saturnclient.common.provider.Providers;
import org.saturnclient.common.ref.asset.SpriteRef;
import org.saturnclient.common.ref.game.EffectRef; import org.saturnclient.common.ref.game.EffectRef;
import org.saturnclient.config.property.BoolProperty; import org.saturnclient.config.property.BoolProperty;
import org.saturnclient.config.property.Property; import org.saturnclient.config.property.Property;
@@ -63,7 +64,12 @@ public class StatusEffectsMod extends Mod implements HudMod {
if (!effect.shouldShowIcon()) if (!effect.shouldShowIcon())
continue; continue;
scope.drawSpriteStretched(effect.getIcon(), 0, 18 * row, 16, 16); SpriteRef sprite = effect.getIcon();
if (sprite == null) {
scope.drawTexture(effect.getIconId(), 0, 18 * row, 0, 0, 16, 16);
} else {
scope.drawSpriteStretched(sprite, 0, 18 * row, 16, 16);
}
scope.drawText(0.5f, durationString(effect), scope.drawText(0.5f, durationString(effect),
18, 18 * row + 3, 18, 18 * row + 3,
layout.font.value, -1); layout.font.value, -1);
@@ -64,19 +64,18 @@ public abstract class Input extends Element {
public void render(RenderScope renderScope, ElementContext ctx) { public void render(RenderScope renderScope, ElementContext ctx) {
checkReset(); checkReset();
renderScope.drawRoundedRectangle(0, 0, width, height, 10, 0xFF000000); renderScope.drawRoundedRectangle(0, 0, width, height, 10, Theme.PRIMARY.value);
int scrollOffset = getScrollOffset(); int scrollOffset = getScrollOffset();
String visibleText = getVisibleText(scrollOffset); String visibleText = getVisibleText(scrollOffset);
int textColor = focused ? 0xFFFFFF : 0xAAAAAA; renderScope.drawText(0.6f, visibleText, 4, 4, Theme.FONT.value, Theme.PRIMARY_FG.value);
renderScope.drawText(0.6f, visibleText, 4, 4, Theme.FONT.value, textColor);
if (focused) { if (focused) {
int cursorX = 4 + int cursorX = 4 +
(int) (Fonts.getWidth(visibleText.substring(0, cursorPosition - scrollOffset), Theme.FONT.value) (int) (Fonts.getWidth(visibleText.substring(0, cursorPosition - scrollOffset), Theme.FONT.value)
* 0.6f); * 0.6f);
renderScope.drawRectangle(cursorX, 2, 1, height - 4, 0xFFFFFFFF); renderScope.drawRectangle(cursorX, 2, 1, height - 4, Theme.ACCENT.value);
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 MiB

After

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 MiB

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 MiB

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 MiB

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 MiB

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 MiB

After

Width:  |  Height:  |  Size: 5.6 MiB