Compare commits
4 Commits
c418e2f36c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aa3a925e5a | |||
| 4ff160ee73 | |||
| d874b88b4e | |||
| d315308c2f |
@@ -7,6 +7,7 @@
|
||||
# Local Hytale runtime and proprietary game data
|
||||
.hytale/
|
||||
dev-runtime/
|
||||
ProjectCapture-devserver/
|
||||
local.properties
|
||||
gradle-local.properties
|
||||
**/HytaleServer.jar
|
||||
|
||||
@@ -23,4 +23,23 @@ Requirements are Java 25 and the checked-in Gradle wrapper. The build downloads
|
||||
./gradlew fanBuild
|
||||
```
|
||||
|
||||
## Isolierter Multiplayer-Testserver
|
||||
|
||||
Nach einer lokalen Hytale-0.5.9-Installation kann eine getrennte, nur lokal
|
||||
gebundene Test-Runtime angelegt werden. Sie kopiert Server und Assets neben das
|
||||
Repository, lässt Launcher-Daten unverändert und wird nie eingecheckt.
|
||||
|
||||
```shell
|
||||
scripts/setup-isolated-dev-server.sh
|
||||
scripts/install-mod-in-dev-server.sh
|
||||
scripts/start-isolated-dev-server.sh
|
||||
```
|
||||
|
||||
Der Testserver lauscht ausschließlich auf `127.0.0.1:5521` und nutzt den
|
||||
normalen Hytale-Authentifizierungsmodus, damit der Launcher-Client beitreten
|
||||
kann. Bei der ersten Ausführung muss der Server einmal über `/auth login device`
|
||||
mit dem Hytale-Konto verknüpft werden. Er ist weder für ein LAN noch für das
|
||||
Internet geeignet. Zwei lokale Clients können damit den gemeinsamen,
|
||||
serverautoritativen Mod-Status prüfen.
|
||||
|
||||
Public releases require a documented policy/rights review; the repository never bundles Hytale server JARs, `Assets.zip`, extracted base assets or secrets. See the [fan-project policy](docs/UNOFFICIAL_FAN_PROJECT_POLICY.md), [third-party notice](THIRD_PARTY_NOTICE.md) and [implementation plan](docs/IMPLEMENTATION_PLAN.md).
|
||||
|
||||
@@ -42,6 +42,14 @@ tasks.register<JavaExec>("validateFanContent") {
|
||||
args("validate", fanContentRoot.asFile.absolutePath)
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn("validateFanContent")
|
||||
tasks.register<JavaExec>("validateReadyFanAssets") {
|
||||
group = "verification"
|
||||
description = "Refuses READY catalog records whose Blockbench export artifacts are absent or malformed."
|
||||
classpath = sourceSets.main.get().runtimeClasspath
|
||||
mainClass = application.mainClass
|
||||
args("ready-assets", fanContentRoot.asFile.absolutePath, rootProject.layout.projectDirectory.asFile.absolutePath)
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn("validateFanContent", "validateReadyFanAssets")
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public final class ContentTool {
|
||||
}
|
||||
|
||||
static int run(String[] args, PrintStream out, PrintStream err) {
|
||||
if (args.length != 2) {
|
||||
if ((args.length != 2) && (args.length != 3 || !"ready-assets".equals(args[0]))) {
|
||||
printUsage(err);
|
||||
return 64;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public final class ContentTool {
|
||||
return switch (args[0]) {
|
||||
case "validate" -> validate(contentRoot, out, err);
|
||||
case "digest" -> digest(contentRoot, out, err);
|
||||
case "ready-assets" -> validateReadyAssets(contentRoot, Path.of(args[2]), out, err);
|
||||
default -> {
|
||||
err.println("Unknown command: " + args[0]);
|
||||
printUsage(err);
|
||||
@@ -59,9 +60,21 @@ public final class ContentTool {
|
||||
}
|
||||
}
|
||||
|
||||
private static int validateReadyAssets(Path contentRoot, Path projectRoot, PrintStream out, PrintStream err) {
|
||||
ValidationReport report = new ReadyAssetValidator().validate(contentRoot, projectRoot);
|
||||
if (!report.isValid()) {
|
||||
err.println("Ready asset validation failed with " + report.issues().size() + " issue(s):");
|
||||
report.issues().forEach(issue -> err.println("- " + issue));
|
||||
return 2;
|
||||
}
|
||||
out.println("Ready asset validation passed: " + contentRoot);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void printUsage(PrintStream stream) {
|
||||
stream.println("Usage:");
|
||||
stream.println(" content-tools validate <content-root>");
|
||||
stream.println(" content-tools digest <content-root>");
|
||||
stream.println(" content-tools ready-assets <content-root> <project-root>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package dev.projectcapture.contenttools;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Verifies the on-disk deliverables for content which claims to be ready for
|
||||
* shipping. Planned records deliberately remain data-only while artists work.
|
||||
*/
|
||||
public final class ReadyAssetValidator {
|
||||
private static final byte[] PNG_SIGNATURE = new byte[] {
|
||||
(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
||||
};
|
||||
|
||||
public ValidationReport validate(Path contentRoot, Path projectRoot) {
|
||||
ValidationReport report = new ValidationReport();
|
||||
Path normalizedProjectRoot = projectRoot.toAbsolutePath().normalize();
|
||||
try {
|
||||
ContentBundle bundle = ContentBundle.load(contentRoot);
|
||||
JsonNode assets = bundle.document("assets.json").path("items");
|
||||
for (int index = 0; index < assets.size(); index++) {
|
||||
JsonNode asset = assets.path(index);
|
||||
if (!"READY".equals(asset.path("status").asText())) {
|
||||
continue;
|
||||
}
|
||||
String location = "assets.json.items[" + index + "]";
|
||||
validateJsonArtifact(asset.path("source").asText(), normalizedProjectRoot, location + ".source", report);
|
||||
validateJsonArtifact(asset.path("model").asText(), normalizedProjectRoot, location + ".model", report);
|
||||
validatePng(asset.path("texture").asText(), normalizedProjectRoot, location + ".texture", report);
|
||||
validateJsonArtifact(asset.path("recipe").asText(), normalizedProjectRoot, location + ".recipe", report);
|
||||
JsonNode animations = asset.path("animations");
|
||||
for (String name : List.of("idle", "walk", "attack")) {
|
||||
validateJsonArtifact(animations.path(name).asText(), normalizedProjectRoot,
|
||||
location + ".animations." + name, report);
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
report.add("READY_ASSET_CATALOG", "assets.json", exception.getMessage());
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
private void validateJsonArtifact(String declaredPath, Path projectRoot, String location, ValidationReport report) {
|
||||
Path artifact = resolve(declaredPath, projectRoot, location, report);
|
||||
if (artifact == null || !requireNonEmptyFile(artifact, location, report)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (ContentBundle.json().readTree(artifact.toFile()) == null) {
|
||||
report.add("READY_ASSET_JSON", location, "Artifact is empty JSON: " + declaredPath);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
report.add("READY_ASSET_JSON", location, "Artifact is not valid JSON: " + declaredPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePng(String declaredPath, Path projectRoot, String location, ValidationReport report) {
|
||||
Path artifact = resolve(declaredPath, projectRoot, location, report);
|
||||
if (artifact == null || !requireNonEmptyFile(artifact, location, report)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
byte[] bytes = Files.readAllBytes(artifact);
|
||||
if (bytes.length < PNG_SIGNATURE.length) {
|
||||
report.add("READY_ASSET_PNG", location, "Texture is too small to be a PNG: " + declaredPath);
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < PNG_SIGNATURE.length; index++) {
|
||||
if (bytes[index] != PNG_SIGNATURE[index]) {
|
||||
report.add("READY_ASSET_PNG", location, "Texture has no PNG signature: " + declaredPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
report.add("READY_ASSET_PNG", location, "Could not inspect texture: " + declaredPath);
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolve(String declaredPath, Path projectRoot, String location, ValidationReport report) {
|
||||
if (declaredPath.isBlank()) {
|
||||
report.add("READY_ASSET_PATH", location, "Ready asset is missing a declared path");
|
||||
return null;
|
||||
}
|
||||
Path artifact = projectRoot.resolve(declaredPath).normalize();
|
||||
if (!artifact.startsWith(projectRoot)) {
|
||||
report.add("READY_ASSET_PATH", location, "Artifact path escapes project root: " + declaredPath);
|
||||
return null;
|
||||
}
|
||||
return artifact;
|
||||
}
|
||||
|
||||
private boolean requireNonEmptyFile(Path artifact, String location, ValidationReport report) {
|
||||
try {
|
||||
if (!Files.isRegularFile(artifact) || Files.size(artifact) == 0) {
|
||||
report.add("READY_ASSET_MISSING", location, "Required generated artifact is missing: " + artifact);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (IOException exception) {
|
||||
report.add("READY_ASSET_MISSING", location, "Could not inspect generated artifact: " + artifact);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,17 @@ class ContentValidatorTest {
|
||||
assertEquals(ContentValidator.UNOFFICIAL_SLICE_SPECIES, ids);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readyAssetRecordsRequireTheirGeneratedFiles() throws IOException {
|
||||
ObjectNode assets = object("assets.json");
|
||||
((ObjectNode) assets.path("items").path(0)).put("status", "READY");
|
||||
write("assets.json", assets);
|
||||
|
||||
ValidationReport report = new ReadyAssetValidator().validate(contentRoot, temporaryDirectory);
|
||||
|
||||
assertTrue(report.containsCode("READY_ASSET_MISSING"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allTwelveSpeciesUseEngineElementRarityAndCaptureSchema() throws IOException {
|
||||
JsonNode species = ContentBundle.load(contentRoot).document("species.json").path("items");
|
||||
|
||||
@@ -395,7 +395,7 @@ new file mode 100644
|
||||
index 0000000..eda0255
|
||||
--- /dev/null
|
||||
+++ b/lib/projectcaptureSecurity.ts
|
||||
@@ -0,0 +1,202 @@
|
||||
@@ -0,0 +1,223 @@
|
||||
+import path from "node:path";
|
||||
+
|
||||
+export const PROJECTCAPTURE_LOOPBACK_HOST = "127.0.0.1" as const;
|
||||
@@ -411,15 +411,36 @@ index 0000000..eda0255
|
||||
+
|
||||
+export interface AssetSandboxFs {
|
||||
+ existsSync(path: string): boolean;
|
||||
+ lstatSync(path: string): {
|
||||
+ lstatSync?(path: string): {
|
||||
+ isDirectory(): boolean;
|
||||
+ isFile(): boolean;
|
||||
+ isSymbolicLink(): boolean;
|
||||
+ };
|
||||
+ statSync?(path: string): {
|
||||
+ isDirectory(): boolean;
|
||||
+ isFile(): boolean;
|
||||
+ };
|
||||
+ realpathSync(path: string): string;
|
||||
+ mkdirSync(path: string, options?: { mode?: number }): unknown;
|
||||
+}
|
||||
+
|
||||
+type AssetStat = {
|
||||
+ isDirectory(): boolean;
|
||||
+ isFile(): boolean;
|
||||
+ isSymbolicLink?(): boolean;
|
||||
+};
|
||||
+
|
||||
+function inspectPath(pathname: string, fs: AssetSandboxFs): AssetStat {
|
||||
+ if (typeof fs.lstatSync === "function") return fs.lstatSync(pathname);
|
||||
+ if (typeof fs.statSync === "function") return fs.statSync(pathname);
|
||||
+ throw new Error("Blockbench filesystem bridge does not expose a safe stat operation.");
|
||||
+}
|
||||
+
|
||||
+function isSymbolicLink(pathname: string, stat: AssetStat, fs: AssetSandboxFs): boolean {
|
||||
+ if (typeof stat.isSymbolicLink === "function") return stat.isSymbolicLink();
|
||||
+ return fs.realpathSync(pathname) !== path.resolve(pathname);
|
||||
+}
|
||||
+
|
||||
+function requireSecretToken(value: string | undefined): string {
|
||||
+ const token = value?.trim();
|
||||
+ if (!token || token.length < PROJECTCAPTURE_MIN_TOKEN_LENGTH) {
|
||||
@@ -520,8 +541,8 @@ index 0000000..eda0255
|
||||
+ if (!fs.existsSync(root)) {
|
||||
+ throw new Error("Configured ProjectCapture asset root does not exist.");
|
||||
+ }
|
||||
+ const rootStat = fs.lstatSync(root);
|
||||
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
||||
+ const rootStat = inspectPath(root, fs);
|
||||
+ if (isSymbolicLink(root, rootStat, fs) || !rootStat.isDirectory()) {
|
||||
+ throw new Error("Configured ProjectCapture asset root must be a real directory, not a symlink.");
|
||||
+ }
|
||||
+ return fs.realpathSync(root);
|
||||
@@ -541,8 +562,8 @@ index 0000000..eda0255
|
||||
+ cursor = path.join(cursor, segment);
|
||||
+ assertInsideRoot(root, cursor);
|
||||
+ if (fs.existsSync(cursor)) {
|
||||
+ const stat = fs.lstatSync(cursor);
|
||||
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
+ const stat = inspectPath(cursor, fs);
|
||||
+ if (isSymbolicLink(cursor, stat, fs) || !stat.isDirectory()) {
|
||||
+ throw new Error("Asset path contains a symlink or non-directory parent.");
|
||||
+ }
|
||||
+ } else {
|
||||
@@ -568,8 +589,8 @@ index 0000000..eda0255
|
||||
+ const candidate = path.resolve(root, relativePath);
|
||||
+ assertInsideRoot(root, candidate);
|
||||
+ if (fs.existsSync(candidate)) {
|
||||
+ const stat = fs.lstatSync(candidate);
|
||||
+ if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
+ const stat = inspectPath(candidate, fs);
|
||||
+ if (isSymbolicLink(candidate, stat, fs) || !stat.isFile()) {
|
||||
+ throw new Error("Asset target must be a regular file and must not be a symlink.");
|
||||
+ }
|
||||
+ }
|
||||
@@ -590,8 +611,8 @@ index 0000000..eda0255
|
||||
+ if (!fs.existsSync(lexicalCandidate)) {
|
||||
+ throw new Error("Requested asset does not exist.");
|
||||
+ }
|
||||
+ const stat = fs.lstatSync(lexicalCandidate);
|
||||
+ if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
+ const stat = inspectPath(lexicalCandidate, fs);
|
||||
+ if (isSymbolicLink(lexicalCandidate, stat, fs) || !stat.isFile()) {
|
||||
+ throw new Error("Requested asset must be a regular file and must not be a symlink.");
|
||||
+ }
|
||||
+ const realCandidate = fs.realpathSync(lexicalCandidate);
|
||||
|
||||
@@ -5,7 +5,7 @@ TOOL_ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
MCP_REPOSITORY="https://github.com/jasonjgardner/blockbench-mcp-plugin.git"
|
||||
MCP_COMMIT="6b069e308fdfc9b0a1c15bc924ca78150815f143"
|
||||
MCP_PATCH="${TOOL_ROOT}/patches/0001-projectcapture-hardening.patch"
|
||||
MCP_PATCH_SHA256="9de0b3767e96db7a68ef4c7082d7c76bf140b4932fc9550ad6e99d9a24a048c3"
|
||||
MCP_PATCH_SHA256="092d5446fcdd1f28d6abcc931be2895727fd9ab3dd56728874b6847a614ed8fa"
|
||||
BUN_VERSION="1.3.14"
|
||||
|
||||
require_command() {
|
||||
|
||||
@@ -28,6 +28,11 @@ val fanModJar = tasks.register<Jar>("fanModJar") {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
|
||||
from(sourceSets.main.get().output)
|
||||
// Keep generated Blockbench exports at their content-declared paths so an
|
||||
// embedded Hytale asset pack can resolve `assets/fan/...` references.
|
||||
from(rootProject.layout.projectDirectory.dir("assets")) {
|
||||
into("assets")
|
||||
}
|
||||
from({
|
||||
configurations.runtimeClasspath.get()
|
||||
.filter { it.exists() }
|
||||
|
||||
@@ -30,10 +30,14 @@ public final class ProjectCapturePlugin extends JavaPlugin {
|
||||
stateStore.initialize();
|
||||
contentCatalog = RuntimeContentCatalog.load(getClass().getClassLoader());
|
||||
multiplayerService = new MultiplayerCreatureService(stateStore, contentCatalog);
|
||||
ProjectCaptureCommands.register(this, multiplayerService);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setup() {
|
||||
ProjectCaptureCommands.register(this, multiplayerService);
|
||||
}
|
||||
|
||||
JsonStateStore stateStore() {
|
||||
return stateStore;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"Name": "Christian Schindler"
|
||||
}
|
||||
],
|
||||
"ServerVersion": "0.5.9",
|
||||
"ServerVersion": "=0.5.9",
|
||||
"Dependencies": {},
|
||||
"OptionalDependencies": {},
|
||||
"DisabledByDefault": false,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package dev.projectcapture.hytale;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class HytaleManifestTest {
|
||||
@Test
|
||||
void pinsTheExactSupportedServerVersionWithValidSemverRangeSyntax() throws Exception {
|
||||
try (InputStream resource = getClass().getClassLoader().getResourceAsStream("manifest.json")) {
|
||||
assertThat(resource).as("plugin manifest resource").isNotNull();
|
||||
JsonNode manifest = new ObjectMapper().readTree(resource);
|
||||
|
||||
assertThat(manifest.path("ServerVersion").asText()).isEqualTo("=0.5.9");
|
||||
assertThat(manifest.path("IncludesAssetPack").asBoolean()).isTrue();
|
||||
assertThat(manifest.path("Main").asText()).isEqualTo("dev.projectcapture.hytale.ProjectCapturePlugin");
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
project_root="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
runtime_root="${PROJECT_CAPTURE_DEV_SERVER:-$(CDPATH= cd -- "${project_root}/.." && pwd -P)/ProjectCapture-devserver}"
|
||||
mods_directory="${runtime_root}/mods"
|
||||
|
||||
[[ -d "${mods_directory}" && ! -L "${mods_directory}" ]] || {
|
||||
printf 'Isolierte Runtime fehlt; zuerst setup-isolated-dev-server.sh ausführen.\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
"${project_root}/gradlew" -p "${project_root}" :hytale-adapter:installToDevMods \
|
||||
-PprojectCapture.devModsDir="${mods_directory}"
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Creates a disposable, local Hytale 0.5.9 server beside the repository. It
|
||||
# never writes to the launcher installation or the project's tracked files.
|
||||
project_root="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
runtime_root="${PROJECT_CAPTURE_DEV_SERVER:-$(CDPATH= cd -- "${project_root}/.." && pwd -P)/ProjectCapture-devserver}"
|
||||
launcher_root="/Users/christianschindler/Library/Application Support/Hytale/install/release/package/game/latest"
|
||||
server_source="${launcher_root}/Server/HytaleServer.jar"
|
||||
assets_source="${launcher_root}/Assets.zip"
|
||||
|
||||
for source_path in "${server_source}" "${assets_source}"; do
|
||||
[[ -f "${source_path}" && ! -L "${source_path}" ]] || {
|
||||
printf 'Fehlende oder unsichere Hytale-Quelldatei: %s\n' "${source_path}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
[[ ! -e "${runtime_root}" && ! -L "${runtime_root}" ]] || {
|
||||
printf 'Ziel existiert bereits; nichts wurde überschrieben: %s\n' "${runtime_root}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "${runtime_root}/Server" "${runtime_root}/mods" "${runtime_root}/universe"
|
||||
cp -- "${server_source}" "${runtime_root}/Server/HytaleServer.jar"
|
||||
cp -- "${assets_source}" "${runtime_root}/Assets.zip"
|
||||
|
||||
printf 'Isolierte Hytale-0.5.9-Runtime angelegt: %s\n' "${runtime_root}"
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Local-only multiplayer test runtime. It uses Hytale's normal authenticated
|
||||
# mode so the launcher client can join; bind remains loopback-only.
|
||||
project_root="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
runtime_root="${PROJECT_CAPTURE_DEV_SERVER:-$(CDPATH= cd -- "${project_root}/.." && pwd -P)/ProjectCapture-devserver}"
|
||||
server_directory="${runtime_root}/Server"
|
||||
|
||||
[[ -f "${server_directory}/HytaleServer.jar" && -f "${runtime_root}/Assets.zip" ]] || {
|
||||
printf 'Isolierte Runtime fehlt; zuerst setup-isolated-dev-server.sh ausführen.\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cd "${server_directory}"
|
||||
export HYTALE_DISABLE_UPDATES=true
|
||||
exec java -Xmx4G -jar HytaleServer.jar \
|
||||
--assets "${runtime_root}/Assets.zip" \
|
||||
--mods "${runtime_root}/mods" \
|
||||
--universe "${runtime_root}/universe" \
|
||||
--auth-mode authenticated \
|
||||
--bind 127.0.0.1:5521 \
|
||||
--disable-sentry
|
||||
Reference in New Issue
Block a user