Skip to content

Commit e45f3b4

Browse files
committed
Merge Hytale config ownership matching
2 parents ab723f7 + e22db26 commit e45f3b4

11 files changed

Lines changed: 251 additions & 7 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { WorldModListItem } from '@/modules/worldlist/api/worldListClient';
2+
3+
/** Hytale derives its data folder from manifest Group + '_' + Name, preserving case. */
4+
export function configOwnerLabel(path: string, mods: WorldModListItem[]): string {
5+
const folder = path.split('/')[0];
6+
const owners = new Map<string, WorldModListItem>();
7+
for (const mod of mods) {
8+
const parts = mod.modId?.split(':');
9+
if (!parts || parts.length !== 2 || parts.some(part => !part.trim() || /[/\\]/.test(part))) continue;
10+
if (`${parts[0]}_${parts[1]}` === folder) owners.set(mod.modId!, mod);
11+
}
12+
if (owners.size > 1) return 'Ambiguous mod';
13+
const owner = owners.values().next().value;
14+
return owner ? `${owner.title} (${owner.modId})` : 'Unattributed';
15+
}

frontend/src/modules/worldlist/views/WorldModListView.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { configOwnerLabel } from '@/modules/worldlist/utils/configOwner';
12
import React, { useEffect, useMemo, useState } from 'react';
23
import { Helmet } from 'react-helmet-async';
34
import { Link, useParams } from 'react-router-dom';
@@ -210,7 +211,7 @@ export const WorldModListView: React.FC = () => {
210211
<p className="text-sm text-slate-500 dark:text-slate-400">Config defaults are included in the ZIP. The launcher adds world configs to the worlds you select and preserves existing files.</p>
211212
{list.configs.map(config => (
212213
<details key={`${config.scope}/${config.path}`} className="rounded-xl border border-slate-200 p-4 dark:border-white/10">
213-
<summary className="cursor-pointer text-sm font-medium">{config.scope === 'GLOBAL' ? 'Global mods' : 'World mods'} / {config.path}</summary>
214+
<summary className="cursor-pointer text-sm font-medium">{configOwnerLabel(config.path, list.mods)} · {config.scope === 'GLOBAL' ? 'Global mods' : 'World mods'} / {config.path}</summary>
214215
<pre className="mt-3 max-h-80 overflow-auto whitespace-pre text-xs">{config.content}</pre>
215216
</details>
216217
))}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { configOwnerLabel } from '@/modules/worldlist/utils/configOwner';
3+
import type { WorldModListItem } from '@/modules/worldlist/api/worldListClient';
4+
const mod = (modId: string, title = 'Marketplace title') => ({ modId, title }) as WorldModListItem;
5+
describe('Hytale config ownership', () => {
6+
it('uses exact Group_Name from mod ID, preserving dots and underscores', () => {
7+
expect(configOwnerLabel('org.example_mods_Fancy_Mod/config.json', [mod('org.example_mods:Fancy_Mod')]))
8+
.toBe('Marketplace title (org.example_mods:Fancy_Mod)');
9+
});
10+
it('does not guess from titles, case differences or custom directories', () => {
11+
const mods = [mod('Author:Plugin', 'Plugin')];
12+
for (const path of ['Plugin/config.json', 'author_Plugin/config.json', 'custom/config.json']) {
13+
expect(configOwnerLabel(path, mods)).toBe('Unattributed');
14+
}
15+
});
16+
it('keeps underscore collisions ambiguous and deduplicates identical IDs', () => {
17+
expect(configOwnerLabel('A_B_C/config.json', [mod('A_B:C'), mod('A:B_C')])).toBe('Ambiguous mod');
18+
expect(configOwnerLabel('A_B/config.json', [mod('A:B'), mod('A:B')])).toBe('Marketplace title (A:B)');
19+
});
20+
});

frontend/tests/modules/worldlist/modpackSeed.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ describe('shared list config seeding', () => {
2424
expect(await zip.file('overrides/Mods/Example/settings.toml')!.async('string')).toBe('value=2\r\n');
2525
expect(await zip.file('overrides/Saves/My World/mods/Example/config.json')!.async('string')).toBe('{"world":true}');
2626
});
27+
it('keeps the manifest-derived folder when converting a list to a modpack', async () => {
28+
const file = await worldListToOverrideFile({ ...base, mods: [{
29+
id: 'item', modId: 'org.example_mods:Fancy_Mod', title: 'Unrelated marketplace title', downloadable: true,
30+
}], configs: [{ scope: 'WORLD', path: 'org.example_mods_Fancy_Mod/nested/Gameplay.json', content: '{}' }] });
31+
const zip = await JSZip.loadAsync(await read(file!));
32+
expect(await zip.file('overrides/Saves/My World/mods/org.example_mods_Fancy_Mod/nested/Gameplay.json')!.async('string')).toBe('{}');
33+
});
2734
it('rejects unsafe paths', async () => {
2835
await expect(worldListToOverrideFile({ ...base, configs: [
2936
{ scope: 'WORLD', path: '../config.json', content: '{}' },

launcher/src/main/java/net/modtale/launcher/config/HytaleConfigFiles.java

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,21 @@ public final class HytaleConfigFiles {
2121

2222
public List<ConfigFile> discover(Path globalMods, Path world) throws IOException {
2323
List<ConfigFile> files = new ArrayList<>(discoverMods(globalMods));
24-
files.addAll(discoverWorld(world));
24+
files.addAll(attribute(discoverWorldFiles(world), HytaleConfigOwnership.read(globalMods, world.resolve("mods"))));
2525
return List.copyOf(files);
2626
}
2727

2828
public List<ConfigFile> discoverMods(Path mods) throws IOException {
2929
List<ConfigFile> files = new ArrayList<>();
3030
scan(mods, "Global mods", files);
31-
return List.copyOf(files);
31+
return attribute(files, HytaleConfigOwnership.read(mods));
3232
}
3333

3434
public List<ConfigFile> discoverWorld(Path world) throws IOException {
35+
return attribute(discoverWorldFiles(world), HytaleConfigOwnership.read(world.resolve("mods")));
36+
}
37+
38+
private List<ConfigFile> discoverWorldFiles(Path world) throws IOException {
3539
List<ConfigFile> files = new ArrayList<>();
3640
scan(world.resolve("mods"), "World mods", files);
3741
add(world, world.resolve("config.json"), "World", files);
@@ -47,12 +51,28 @@ public List<ConfigFile> discoverWorld(Path world) throws IOException {
4751
return List.copyOf(files);
4852
}
4953

54+
private List<ConfigFile> attribute(List<ConfigFile> files, HytaleConfigOwnership ownership) {
55+
return files.stream().map(file -> {
56+
if (!(file.label().startsWith("Global mods / ") || file.label().startsWith("World mods / "))) return file;
57+
Path relative = file.root().relativize(file.path());
58+
Set<String> owners = relative.getNameCount() < 2 ? Set.of() : ownership.owners(relative.getName(0).toString());
59+
String id = owners.size() == 1 ? owners.iterator().next() : "";
60+
String owner = id.isEmpty() ? (owners.isEmpty() ? "Unattributed" : "Ambiguous mod") : id;
61+
String scope = file.label().startsWith("Global") ? "Global mods" : "World mods";
62+
return new ConfigFile(file.root(), file.path(), scope + " / " + owner + " / "
63+
+ relative.toString().replace('\\', '/'), id);
64+
}).toList();
65+
}
66+
5067
private void scan(Path root, String scope, List<ConfigFile> files) throws IOException {
5168
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) return;
5269
try (Stream<Path> paths = Files.walk(root, 8)) {
5370
List<Path> candidates = paths.limit(20_001).toList();
5471
if (candidates.size() > 20_000) throw new IOException("Too many files to scan for configs.");
5572
for (Path path : candidates) {
73+
// Loose asset packs contain game JSON, not editable plugin settings.
74+
Path relative = root.relativize(path);
75+
if (relative.getNameCount() > 1 && Files.isRegularFile(root.resolve(relative.getName(0)).resolve("manifest.json"), LinkOption.NOFOLLOW_LINKS)) continue;
5676
add(root, path, scope, files);
5777
if (files.size() > 2000) throw new IOException("Too many config files to display.");
5878
}
@@ -133,7 +153,8 @@ private Path checked(ConfigFile file) throws IOException {
133153
return path;
134154
}
135155

136-
public record ConfigFile(Path root, Path path, String label) {
156+
public record ConfigFile(Path root, Path path, String label, String pluginId) {
157+
public ConfigFile(Path root, Path path, String label) { this(root, path, label, ""); }
137158
@Override public String toString() { return label; }
138159
}
139160
public record Snapshot(ConfigFile file, String text) {}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package net.modtale.launcher.config;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import java.io.IOException;
6+
import java.io.InputStream;
7+
import java.nio.file.Files;
8+
import java.nio.file.LinkOption;
9+
import java.nio.file.Path;
10+
import java.util.*;
11+
import java.util.zip.ZipFile;
12+
13+
/** Matches the exact manifest Group + "_" + Name used by PendingLoadJavaPlugin.load(). */
14+
final class HytaleConfigOwnership {
15+
private static final ObjectMapper JSON = new ObjectMapper();
16+
private static final int MAX_MANIFEST_BYTES = 1024 * 1024;
17+
private final Map<String, Set<String>> idsByFolder = new HashMap<>();
18+
19+
static HytaleConfigOwnership read(Path... roots) throws IOException {
20+
var ownership = new HytaleConfigOwnership();
21+
for (Path root : new LinkedHashSet<>(Arrays.asList(roots))) {
22+
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) continue;
23+
try (var paths = Files.list(root)) {
24+
var candidates = paths.limit(20_001).toList();
25+
if (candidates.size() > 20_000) throw new IOException("Too many mods to identify configs.");
26+
for (Path path : candidates) ownership.readManifest(path);
27+
}
28+
}
29+
return ownership;
30+
}
31+
32+
private void readManifest(Path path) {
33+
if (Files.isSymbolicLink(path)) return;
34+
String name = path.getFileName().toString().toLowerCase(Locale.ROOT);
35+
try {
36+
if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
37+
Path manifest = path.resolve("manifest.json");
38+
if (!Files.isRegularFile(manifest, LinkOption.NOFOLLOW_LINKS)) return;
39+
try (var input = Files.newInputStream(manifest)) { add(read(input), null, 0); }
40+
} else if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)
41+
&& (name.endsWith(".jar") || name.endsWith(".zip"))) {
42+
try (var zip = new ZipFile(path.toFile())) {
43+
var entry = zip.getEntry("manifest.json");
44+
if (entry != null) try (var input = zip.getInputStream(entry)) { add(read(input), null, 0); }
45+
}
46+
}
47+
} catch (IOException ignored) {
48+
// An unreadable manifest provides no evidence of config ownership.
49+
}
50+
}
51+
52+
private JsonNode read(InputStream input) throws IOException {
53+
byte[] bytes = input.readNBytes(MAX_MANIFEST_BYTES + 1);
54+
if (bytes.length > MAX_MANIFEST_BYTES) throw new IOException("Manifest is too large.");
55+
return JSON.readTree(bytes);
56+
}
57+
58+
private void add(JsonNode manifest, String inheritedGroup, int depth) {
59+
if (manifest == null || !manifest.isObject() || depth > 16) return;
60+
String group = manifest.path("Group").asText(inheritedGroup);
61+
String name = manifest.path("Name").asText("");
62+
if (component(group) && component(name)) {
63+
idsByFolder.computeIfAbsent(group + "_" + name, ignored -> new TreeSet<>()).add(group + ":" + name);
64+
}
65+
for (JsonNode child : manifest.path("SubPlugins")) add(child, group, depth + 1);
66+
}
67+
68+
private static boolean component(String value) {
69+
return value != null && !value.isBlank() && value.chars().noneMatch(c -> c < 32 || c == ':' || c == '/' || c == '\\');
70+
}
71+
72+
Set<String> owners(String folder) { return idsByFolder.getOrDefault(folder, Set.of()); }
73+
}

launcher/src/main/java/net/modtale/launcher/ui/library/ConfigEditorModal.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ final class ConfigEditorModal {
4747
void show(Path globalMods, Path world, String worldName) {
4848
Label title = new Label("Configs · " + worldName);
4949
title.getStyleClass().add("config-editor-title");
50-
Label hint = new Label("Close Hytale before editing. JSON is validated; other formats are saved as text. A backup is kept beside each saved file. Configs are saved in launcher settings and sync when signed in.");
50+
Label hint = new Label("Close Hytale before editing. JSON is validated; other formats are saved as text. A backup is kept beside each saved file. Configs are saved in launcher settings and sync when signed in. Mod ownership uses manifest IDs; custom or missing mod folders appear as Unattributed.");
5151
hint.setWrapText(true);
5252
hint.getStyleClass().add("library-muted-text");
5353
search.setPromptText("Search config files");

launcher/src/main/java/net/modtale/launcher/ui/library/ShareConfigSelectionModal.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ static void show(StackPane host, List<ConfigFile> files, Consumer<List<ConfigFil
2424
overlay.setPadding(new Insets(24));
2525
Label title = new Label("Include configs in this list");
2626
title.getStyleClass().add("config-editor-title");
27-
Label hint = new Label("Selected files will be public with the shared list. Choose only the mod settings you want to share. Leave everything unchecked to share mods only.");
27+
Label hint = new Label("Selected files will be public with the shared list. Choose only the mod settings you want to share. Leave everything unchecked to share mods only. Unattributed folders could not be matched to a mod; their original paths are preserved.");
2828
hint.setWrapText(true);
2929
Map<ConfigFile, CheckBox> choices = new LinkedHashMap<>();
3030
VBox rows = new VBox(10);

launcher/src/test/java/net/modtale/launcher/config/HytaleConfigFilesTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ void discoversGlobalAndWorldConfigsWithoutManifestsArchivesOrOtherWorlds() throw
2424
write("Saves/Other/mods/Example_Plugin/config.json", "{}");
2525
List<ConfigFile> files = discover();
2626
assertEquals(4, files.size());
27-
assertTrue(files.stream().anyMatch(file -> file.label().equals("World mods / Example_Plugin/config.json")));
27+
assertTrue(files.stream().anyMatch(file -> file.label().equals("World mods / Unattributed / Example_Plugin/config.json")));
2828
}
2929

3030
@Test
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package net.modtale.launcher.config;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
import java.nio.file.Files;
5+
import java.nio.file.Path;
6+
import java.util.List;
7+
import java.util.zip.ZipEntry;
8+
import java.util.zip.ZipOutputStream;
9+
import net.modtale.launcher.install.WorldListConfigInstaller;
10+
import org.junit.jupiter.api.Test;
11+
import org.junit.jupiter.api.io.TempDir;
12+
13+
class HytaleConfigOwnershipTest {
14+
@TempDir Path directory;
15+
16+
@Test void matchesManifestIdentityRatherThanFilenameNameOrCase() throws Exception {
17+
jar("Mods/unrelated-download-name.jar", "{\"Group\":\"com.azuredoom\",\"Name\":\"levelingcore\"}");
18+
jar("Mods/other.jar", "{\"Group\":\"Other\",\"Name\":\"levelingcore\"}");
19+
config("world/mods/com.azuredoom_levelingcore/config.json");
20+
config("world/mods/Other_levelingcore/settings.toml");
21+
config("world/mods/com.azuredoom_Levelingcore/config.json");
22+
config("world/mods/unrelated-download-name/config.json");
23+
config("world/mods/levelingcore/config.json");
24+
var found = new HytaleConfigFiles().discover(directory.resolve("Mods"), directory.resolve("world"));
25+
assertEquals(5, found.size());
26+
assertEquals(2, found.stream().filter(file -> !file.pluginId().isEmpty()).count());
27+
assertTrue(found.stream().anyMatch(file -> file.pluginId().equals("com.azuredoom:levelingcore")
28+
&& file.label().contains("World mods / com.azuredoom:levelingcore /")));
29+
assertEquals(3, found.stream().filter(file -> file.label().contains("Unattributed")).count());
30+
}
31+
32+
@Test void handlesLocalWorldModsInheritedSubPluginsAndAmbiguousUnderscores() throws Exception {
33+
jar("world/mods/bundle.jar", "{\"Group\":\"Author\",\"Name\":\"Parent\",\"SubPlugins\":[{\"Name\":\"Child\"}]}");
34+
jar("Mods/one.jar", "{\"Group\":\"A_B\",\"Name\":\"C\"}");
35+
jar("Mods/two.jar", "{\"Group\":\"A\",\"Name\":\"B_C\"}");
36+
config("world/mods/Author_Child/config.json");
37+
config("world/mods/A_B_C/config.json");
38+
var found = new HytaleConfigFiles().discover(directory.resolve("Mods"), directory.resolve("world"));
39+
assertTrue(found.stream().anyMatch(file -> file.pluginId().equals("Author:Child")));
40+
assertTrue(found.stream().anyMatch(file -> file.pluginId().isEmpty() && file.label().contains("Ambiguous mod")));
41+
}
42+
43+
@Test void excludesLooseAssetJsonAndDoesNotGuessCustomFolders() throws Exception {
44+
Path manifest = config("Mods/loose-pack/manifest.json");
45+
Files.writeString(manifest, "{\"Group\":\"Author\",\"Name\":\"Assets\"}");
46+
config("Mods/loose-pack/Server/Item/Items/example.json");
47+
jar("Mods/corrupt.jar", "{broken");
48+
config("world/mods/custom-author-folder/nested/settings.json");
49+
var found = new HytaleConfigFiles().discover(directory.resolve("Mods"), directory.resolve("world"));
50+
assertEquals(1, found.size());
51+
assertEquals("", found.getFirst().pluginId());
52+
assertTrue(found.getFirst().label().contains("Unattributed"));
53+
}
54+
55+
@Test void sharedConfigsPreserveExactManifestFolderAndOwnerAfterRebasing() throws Exception {
56+
jar("Mods/modtale-project-slug-v2.jar", "{\"Group\":\"org.example_mods\",\"Name\":\"Fancy_Mod\"}");
57+
config("world/mods/org.example_mods_Fancy_Mod/nested/Gameplay.json");
58+
var capture = new WorldListConfigCapture();
59+
var found = capture.discover(directory.resolve("Mods"), directory.resolve("world"));
60+
assertEquals("org.example_mods:Fancy_Mod", found.getFirst().pluginId());
61+
var configs = capture.capture(found, directory.resolve("Mods"), directory.resolve("world"));
62+
assertEquals("org.example_mods_Fancy_Mod/nested/Gameplay.json", configs.getFirst().path());
63+
WorldListConfigInstaller.install(configs, "WORLD", directory.resolve("recipient/mods"));
64+
var restored = new HytaleConfigFiles().discover(directory.resolve("Mods"), directory.resolve("recipient"));
65+
assertEquals(List.of("org.example_mods:Fancy_Mod"), restored.stream().map(HytaleConfigFiles.ConfigFile::pluginId).toList());
66+
}
67+
68+
private Path config(String relative) throws Exception {
69+
Path path = directory.resolve(relative); Files.createDirectories(path.getParent()); Files.writeString(path, "{}"); return path;
70+
}
71+
private void jar(String relative, String manifest) throws Exception {
72+
Path path = directory.resolve(relative); Files.createDirectories(path.getParent());
73+
try (var zip = new ZipOutputStream(Files.newOutputStream(path))) {
74+
zip.putNextEntry(new ZipEntry("manifest.json")); zip.write(manifest.getBytes(java.nio.charset.StandardCharsets.UTF_8)); zip.closeEntry();
75+
}
76+
}
77+
}

0 commit comments

Comments
 (0)